├── .dockerignore ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── build.yml │ └── documentation.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.js ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── custom ├── emails │ ├── activate-account │ │ ├── html.ejs │ │ └── subject.ejs │ ├── change-email │ │ ├── html.ejs │ │ └── subject.ejs │ ├── lost-password │ │ ├── html.ejs │ │ └── subject.ejs │ ├── magic-link │ │ ├── html.ejs │ │ └── subject.ejs │ └── notify-email-change │ │ ├── html.ejs │ │ └── subject.ejs ├── keys │ └── .gitkeep └── storage-rules │ └── rules.yaml ├── docker ├── dev │ ├── .gitignore │ ├── Dockerfile │ └── docker-compose-example.yaml ├── prod │ └── Dockerfile └── test │ └── docker-compose-test.yaml ├── docs ├── .gitignore ├── .prettierrc.js ├── README.md ├── babel.config.js ├── docs │ ├── api-reference.md │ ├── emails.md │ ├── environment-variables.md │ ├── getting-started │ │ ├── configuration.md │ │ └── setup.md │ ├── intro.md │ ├── oauth-providers.md │ └── storage-rules.md ├── docusaurus.config.js ├── package.json ├── sidebars.js ├── src │ ├── components │ │ ├── HomepageFeatures.js │ │ └── HomepageFeatures.module.css │ ├── css │ │ └── custom.css │ └── pages │ │ ├── index.js │ │ ├── index.module.css │ │ └── markdown-page.md ├── static │ ├── .nojekyll │ └── img │ │ ├── authentication.svg │ │ ├── docusaurus.png │ │ ├── favicon.ico │ │ ├── logo.png │ │ ├── logo.svg │ │ ├── programming.svg │ │ ├── storage.svg │ │ └── tutorial │ │ ├── docsVersionDropdown.png │ │ └── localeDropdown.png ├── tsconfig.json └── yarn.lock ├── jest.config.js ├── migrations └── 00001_create-initial-tables.sql ├── package.json ├── prod-paths.js ├── src ├── errors.ts ├── limiter.ts ├── middlewares │ └── auth.ts ├── routes │ ├── auth │ │ ├── activate.ts │ │ ├── auth.test.ts │ │ ├── change-email │ │ │ ├── direct-change.ts │ │ │ ├── email.test.ts │ │ │ ├── index.ts │ │ │ ├── request-verification.ts │ │ │ ├── utils.ts │ │ │ └── verify-and-change.ts │ │ ├── change-password │ │ │ ├── change.test.ts │ │ │ ├── change.ts │ │ │ ├── index.ts │ │ │ ├── lost.test.ts │ │ │ ├── lost.ts │ │ │ └── reset.ts │ │ ├── delete.ts │ │ ├── index.ts │ │ ├── jwks.ts │ │ ├── login.ts │ │ ├── logout.ts │ │ ├── magic-link.ts │ │ ├── mfa │ │ │ ├── disable.ts │ │ │ ├── enable.ts │ │ │ ├── generate.ts │ │ │ ├── index.ts │ │ │ ├── mfa.test.ts │ │ │ └── totp.ts │ │ ├── providers │ │ │ ├── apple.ts │ │ │ ├── facebook.ts │ │ │ ├── github.ts │ │ │ ├── google.ts │ │ │ ├── index.ts │ │ │ ├── linkedin.ts │ │ │ ├── spotify.ts │ │ │ ├── twitter.ts │ │ │ ├── utils.ts │ │ │ └── windowslive.ts │ │ ├── register.ts │ │ └── token │ │ │ ├── index.ts │ │ │ ├── refresh.ts │ │ │ ├── revoke.ts │ │ │ └── token.test.ts │ ├── index.ts │ └── storage │ │ ├── delete.ts │ │ ├── get.ts │ │ ├── index.ts │ │ ├── list.ts │ │ ├── list_get.ts │ │ ├── storage.test.ts │ │ ├── upload.ts │ │ └── utils.ts ├── server.ts ├── shared │ ├── config │ │ ├── application.ts │ │ ├── authentication │ │ │ ├── cookies.ts │ │ │ ├── index.ts │ │ │ ├── jwt.ts │ │ │ ├── mfa.ts │ │ │ ├── providers.ts │ │ │ └── registration.ts │ │ ├── headers.ts │ │ ├── index.ts │ │ ├── storage.ts │ │ └── utils.ts │ ├── cookies.ts │ ├── email.ts │ ├── helpers.ts │ ├── jwt.ts │ ├── metadata.ts │ ├── migrations.ts │ ├── queries.ts │ ├── request.ts │ ├── s3.ts │ ├── types.ts │ └── validation.ts ├── start.ts ├── test │ ├── global-setup.ts │ ├── server.ts │ ├── setup.ts │ ├── supertest-shared-utils.ts │ └── utils.ts ├── ts-start.ts └── types │ ├── @nicokaiser │ └── passport-apple.d.ts │ ├── notevil.d.ts │ ├── passport-generic-oauth.d.ts │ └── passport-windowslive.d.ts ├── test-mocks ├── example.jpg └── migrations │ └── 1585679214182_custom_user_column │ ├── down.sql │ └── up.sql ├── tsconfig.json └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .github 3 | .vscode 4 | coverage 5 | docs 6 | examples 7 | mock-config 8 | node_modules 9 | *.sh 10 | .editorconfig 11 | .prettierignore 12 | config.yaml 13 | docker-compose* 14 | Dockerfile* 15 | jest* 16 | npm-debug.log 17 | yarn-error.log 18 | **/*.test.ts 19 | src/test 20 | **/*.pem 21 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | indent_size = 2 8 | end_of_line = lf 9 | indent_style = space 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | *.js 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | rules: { 4 | '@typescript-eslint/camelcase': 'off', 5 | 'jest/expect-expect': 'off', 6 | 'jest/no-test-callback': 'off', 7 | '@typescript-eslint/explicit-function-return-type': 'off' 8 | }, 9 | parser: '@typescript-eslint/parser', 10 | plugins: [ 11 | '@typescript-eslint', 12 | 'jest' 13 | ], 14 | extends: [ 15 | 'eslint:recommended', 16 | 'plugin:@typescript-eslint/eslint-recommended', 17 | 'plugin:@typescript-eslint/recommended', 18 | 'prettier/@typescript-eslint', 19 | 'plugin:jest/recommended', 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | pull_request: 4 | branches: [master] 5 | paths-ignore: 6 | - 'docs/**' 7 | push: 8 | branches: [master] 9 | paths-ignore: 10 | - 'docs/**' 11 | release: 12 | types: [published] 13 | env: 14 | HASURA_GRAPHQL_ADMIN_SECRET: test_secret_key 15 | JWT_ALGORITHM: HS256 16 | JWT_KEY: never_use_this_secret_key_in_production_this_is_only_for_CI_testing_098hu32r4389ufb4n38994321 17 | POSTGRES_PASSWORD: postgrespassword 18 | S3_BUCKET: test-bucket 19 | S3_ACCESS_KEY_ID: 'minio_access_key' 20 | S3_SECRET_ACCESS_KEY: 'minio_secret_key' 21 | jobs: 22 | test: 23 | runs-on: ubuntu-latest 24 | timeout-minutes: 10 25 | services: 26 | postgres: 27 | image: nhost/postgres:12-v0.0.6 28 | env: 29 | POSTGRES_PASSWORD: ${{ env.POSTGRES_PASSWORD }} 30 | options: --restart always --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 31 | graphql-engine: 32 | image: hasura/graphql-engine:v2.0.1 33 | env: 34 | HASURA_GRAPHQL_ENABLE_TELEMETRY: 'false' 35 | HASURA_GRAPHQL_ADMIN_SECRET: ${{ env.HASURA_GRAPHQL_ADMIN_SECRET }} 36 | HASURA_GRAPHQL_DATABASE_URL: postgres://postgres:${{ env.POSTGRES_PASSWORD }}@postgres:5432/postgres 37 | HASURA_GRAPHQL_JWT_SECRET: '{"type": "${{ env.JWT_ALGORITHM }}", "key": "${{ env.JWT_KEY }}"}' 38 | options: >- 39 | --restart always 40 | minio: 41 | image: bitnami/minio:2020.7.31 42 | env: 43 | MINIO_ACCESS_KEY: ${{ env.S3_ACCESS_KEY_ID }} 44 | MINIO_SECRET_KEY: ${{ env.S3_SECRET_ACCESS_KEY }} 45 | MINIO_DEFAULT_BUCKETS: ${{ env.S3_BUCKET }} 46 | options: --restart always 47 | ports: 48 | - 9000:9000 49 | mailhog: 50 | image: mailhog/mailhog 51 | env: 52 | SMTP_HOST: mailhog 53 | SMTP_PORT: 1025 54 | SMTP_PASS: password 55 | SMTP_USER: user 56 | SMTP_SECURE: 'false' 57 | SMTP_SENDER: hbp@hbp.com 58 | container: 59 | image: node:14 60 | env: 61 | HOST: 0.0.0.0 62 | PORT: 4000 63 | JWT_CUSTOM_FIELDS: 'name' 64 | HASURA_ENDPOINT: http://graphql-engine:8080/v1/graphql 65 | HASURA_GRAPHQL_ADMIN_SECRET: ${{ env.HASURA_GRAPHQL_ADMIN_SECRET }} 66 | JWT_ALGORITHM: ${{ env.JWT_ALGORITHM }} 67 | JWT_KEY: ${{ env.JWT_KEY }} 68 | S3_ENDPOINT: 'minio:9000' 69 | S3_SSL_ENABLED: 'false' 70 | S3_BUCKET: ${{ env.S3_BUCKET }} 71 | S3_ACCESS_KEY_ID: ${{ env.S3_ACCESS_KEY_ID }} 72 | S3_SECRET_ACCESS_KEY: ${{ env.S3_SECRET_ACCESS_KEY }} 73 | DATABASE_URL: postgres://postgres:${{ env.POSTGRES_PASSWORD }}@postgres:5432/postgres 74 | PGOPTIONS: '-c search_path=auth' 75 | AUTH_ENABLED: 'true' 76 | MAGIC_LINK_ENABLED: 'true' 77 | AUTH_LOCAL_USERS_ENABLED: 'true' 78 | AUTO_ACTIVATE_NEW_USERS: 'true' 79 | AUTH_ANONYMOUS_USERS_ACTIVE: 'false' 80 | PROVIDER_SUCCESS_REDIRECT: 'http://localhost:3001/success' 81 | PROVIDER_FAILURE_REDIRECT: 'http://localhost:3001/failed' 82 | ALLOW_USER_SELF_DELETE: 'false' 83 | LOST_PASSWORD_ENABLED: 'true' 84 | MIN_PASSWORD_LENGTH: 4 85 | CHANGE_EMAIL_ENABLED: 'true' 86 | VERIFY_EMAILS: 'true' 87 | NOTIFY_EMAIL_CHANGE: 'true' 88 | EMAILS_ENABLED: 'true' 89 | HIBP_ENABLED: 'false' 90 | DEFAULT_ALLOWED_USER_ROLES: 'user,me,editor' 91 | ALLOWED_USER_ROLES: 'user,me,editor' 92 | REGISTRATION_CUSTOM_FIELDS: 'name' 93 | COOKIE_SECURE: 'false' 94 | COOKIE_SECRET: 'somelongvalue' 95 | SMTP_HOST: mailhog 96 | SMTP_PORT: 1025 97 | SMTP_PASS: password 98 | SMTP_USER: user 99 | SMTP_SECURE: 'false' 100 | SMTP_SENDER: hbp@hbp.com 101 | REDIRECT_URL_SUCCESS: 'http://localhost:3000' 102 | REDIRECT_URL_ERROR: 'http://localhost:3000/fail' 103 | options: --hostname hasura-backend-plus 104 | steps: 105 | - uses: actions/checkout@v2 106 | - name: Setup Node 107 | uses: actions/setup-node@v1 108 | with: 109 | node-version: '14.x' 110 | - name: Get yarn cache directory path 111 | id: yarn-cache-dir-path 112 | run: echo "::set-output name=dir::$(yarn cache dir)" 113 | - name: Cache dependencies 114 | uses: actions/cache@v1 115 | with: 116 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 117 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 118 | restore-keys: | 119 | ${{ runner.os }}-yarn- 120 | - name: Install dependencies 121 | run: yarn 122 | - name: Lint files 123 | run: yarn lint 124 | - name: Run Jest tests 125 | run: yarn test --coverage 126 | - name: Upload test results 127 | uses: actions/upload-artifact@v1 128 | with: 129 | name: coverage 130 | path: coverage 131 | coverage: 132 | needs: test 133 | if: github.event_name == 'push' 134 | runs-on: ubuntu-latest 135 | steps: 136 | - uses: actions/checkout@v2 137 | - name: Download coverage results 138 | uses: actions/download-artifact@v1 139 | with: 140 | name: coverage 141 | - name: Publish to CodeCov 142 | uses: codecov/codecov-action@v1 143 | with: 144 | file: ./coverage/clover.xml 145 | publish: 146 | needs: test 147 | runs-on: ubuntu-latest 148 | steps: 149 | - uses: actions/checkout@v2 150 | - name: Build and publish to Docker Hub 151 | uses: docker/build-push-action@v1 152 | with: 153 | username: ${{ secrets.DOCKER_USERNAME }} 154 | password: ${{ secrets.DOCKER_PASSWORD }} 155 | repository: nhost/hasura-backend-plus 156 | path: . 157 | dockerfile: docker/prod/Dockerfile 158 | tag_with_ref: true 159 | tag_with_sha: true 160 | -------------------------------------------------------------------------------- /.github/workflows/documentation.yml: -------------------------------------------------------------------------------- 1 | name: documentation 2 | 3 | on: 4 | pull_request: 5 | branches: [master] 6 | paths: 7 | - 'docs/**' 8 | push: 9 | branches: [master] 10 | paths: 11 | - 'docs/**' 12 | 13 | jobs: 14 | checks: 15 | if: github.event_name != 'push' 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v1 19 | - uses: actions/setup-node@v1 20 | with: 21 | node-version: '12.x' 22 | - name: Test Build 23 | run: | 24 | if [ -e yarn.lock ]; then 25 | yarn install --frozen-lockfile 26 | elif [ -e package-lock.json ]; then 27 | npm ci 28 | else 29 | npm i 30 | fi 31 | npm run build 32 | gh-release: 33 | if: github.event_name != 'pull_request' 34 | runs-on: ubuntu-latest 35 | steps: 36 | - uses: actions/checkout@v1 37 | - uses: actions/setup-node@v1 38 | with: 39 | node-version: '12.x' 40 | - uses: webfactory/ssh-agent@v0.5.0 41 | with: 42 | ssh-private-key: ${{ secrets.GH_PAGES_DEPLOY }} 43 | - name: Release to GitHub Pages 44 | env: 45 | USE_SSH: true 46 | GIT_USER: git 47 | run: | 48 | cd docs 49 | git config --global user.email "actions@github.com" 50 | git config --global user.name "gh-actions" 51 | if [ -e yarn.lock ]; then 52 | yarn install --frozen-lockfile 53 | elif [ -e package-lock.json ]; then 54 | npm ci 55 | else 56 | npm i 57 | fi 58 | npm run deploy 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | .env* 3 | !.env.ci 4 | !.env*.example 5 | *.log 6 | coverage 7 | private.pem 8 | node_modules 9 | data 10 | yarn-error.log 11 | package-lock.json 12 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | coverage -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: false, 3 | printWidth: 100, 4 | singleQuote: true, 5 | trailingComma: 'none' 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | ] 5 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "name": "Attach", 5 | "type": "node", 6 | "request": "attach", 7 | "port": 9229, 8 | "sourceMaps": true, 9 | "remoteRoot": "/app", 10 | "skipFiles": [ 11 | "/**" 12 | ] 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.validate": ["javascript", "typescript"], 3 | "editor.formatOnSave": true 4 | } 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Hasura Backend Plus 2 | 3 | ## Dependencies 4 | 5 | - [Yarn](https://classic.yarnpkg.com/en/docs/install/#mac-stable) 6 | - [Docker](https://www.docker.com/) 7 | - [Docker Compose](https://docs.docker.com/compose/) 8 | 9 | ## Get Started 10 | 11 | 1. [Fork](https://docs.github.com/en/get-started/quickstart/fork-a-repo) this repository to your own GitHub account and then [clone](https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/cloning-a-repository-from-github/cloning-a-repository) it to your local device. 12 | 2. Create a new branch: `git checkout -b MY_BRANCH_NAME` 13 | 3. Install the dependencies: `yarn` 14 | 4. Run `yarn dev` to build and watch for code changes 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Nhost 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 | ## ⚠️ Deprecation Notice ⚠️ 2 | 3 | Hasura Backend Plus is no longer actively maintained. Instead, use these repositories: 4 | 5 | - [Hasura Auth](https://github.com/nhost/hasura-auth) 6 | - [Hasura Storage](https://github.com/nhost/hasura-storage) 7 | 8 | Both services are integrated into Nhost: 9 | - [Nhost](https://github.com/nhost/nhost) 10 | 11 | ---- 12 | 13 | 14 |

15 | 16 | HBP 17 | 18 |

19 |

Hasura Backend Plus (HBP)

20 |

Authentication & Storage for Hasura

21 | 22 |

23 | 24 | 25 | license: MIT 26 | 27 | 28 | commitizen: friendly 29 | 30 | 31 | code style: prettier 32 | 33 | 34 | 35 | 36 | 37 | 39 | 40 |

41 | 42 | --- 43 | 44 | 45 | For detailed usage and installation instructions check out the [documentation](https://nhost.github.io/hasura-backend-plus/). 46 | 47 | ## Core Features: 48 | 49 | - 🎨 Fully customizable with sensible defaults. 50 | - 🚀 Easy to setup, can be deployed anywhere. 51 | - 🔑 Two-factor authentication support. 52 | - 🔑 Third-party OAuth providers: Google, GitHub, Facebook, Apple, Twitter, Microsoft Live, Linkedin, Spotify. 53 | - 📁 Highly customisable storage rules on any S3-compatible instance. 54 | - 📨 Optional email account verification. 55 | - 📨 Secure email and password change. 56 | - 🔑 JWKS endpoint. 57 | - ✅ Optional checking for [Pwned Passwords](https://haveibeenpwned.com/Passwords). 58 | - 📈 Rate limiting. 59 | - 👨‍💻 Written 100% in [TypeScript](https://www.typescriptlang.org). 60 | 61 | ## Nhost 62 | 63 | The easiest way to deploy HBP is with our official [Nhost](https://nhost.io) managed service. Get your perfectly configured backend with PostgreSQL, Hasura and Hasura Backend Plus and save yourself hours of maintenance per month. 64 | 65 | All [Nhost](https://nhost.io) projects are built on open source software so you can make real-time web and mobile apps fast 🚀. 66 | 67 | 68 | 72 | 73 | 74 | ## 🤝 Contributing 75 | 76 | Contributions and issues are welcome. 77 | 78 | Feel free to check the [issues page](https://github.com/nhost/hasura-backend-plus/issues). 79 | 80 | ## Show your support 81 | 82 | Give a ⭐️ if this project helped you! 83 | 84 | ## 📝 License 85 | 86 | This project is [MIT](LICENSE) licensed. 87 | -------------------------------------------------------------------------------- /custom/emails/activate-account/html.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 20 | 21 | 22 | 23 |

Hello <%= display_name %>

24 |

Please confirm your email address by clicking the button below:

25 | 26 | 28 |

Thanks,
The Team

29 | 30 | 31 | -------------------------------------------------------------------------------- /custom/emails/activate-account/subject.ejs: -------------------------------------------------------------------------------- 1 | Confirm your email address -------------------------------------------------------------------------------- /custom/emails/change-email/html.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 11 | 12 | 13 | 14 |

Hello.

15 |

Someone requested to change the email for your account.

16 |

If this was not you, please disregard this message.

17 |
18 |

Proceed the email change by using the following code:

19 |

<%= ticket %>

20 |

Thanks,
The Team

21 | 22 | 23 | -------------------------------------------------------------------------------- /custom/emails/change-email/subject.ejs: -------------------------------------------------------------------------------- 1 | Change your email address -------------------------------------------------------------------------------- /custom/emails/lost-password/html.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 11 | 12 | 13 | 14 |

Hello.

15 |

Someone requested to reset the password for your account.

16 |

If this was not you, please disregard this message.

17 |
18 |

Proceed the password reset by using the following code:

19 |

<%= ticket %>

20 |

Thanks,
The Team

21 | 22 | 23 | -------------------------------------------------------------------------------- /custom/emails/lost-password/subject.ejs: -------------------------------------------------------------------------------- 1 | Reset your password 2 | -------------------------------------------------------------------------------- /custom/emails/magic-link/html.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 21 | 22 | 23 | 24 |

Hello <%= display_name %> 25 |

26 |

Use this link to securely <%= action %>:

27 | 28 | 31 | 32 |

33 | You can also copy & paste this URL into your browser: 34 | 35 | <%= url %>/auth/magic-link?token=<%= token %>&action=<%= action_url %> 36 |

37 |

Thanks,
The Team

38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /custom/emails/magic-link/subject.ejs: -------------------------------------------------------------------------------- 1 | Secure <%= action %> link -------------------------------------------------------------------------------- /custom/emails/notify-email-change/html.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 11 | 12 | 13 | 14 |

Hello.

15 |

The email attached to your account has been changed.

16 |

If you did not ask for this, please contact our services.

17 |
18 |

Thanks,
The Team

19 | 20 | 21 | -------------------------------------------------------------------------------- /custom/emails/notify-email-change/subject.ejs: -------------------------------------------------------------------------------- 1 | The email attached to your account has been changed 2 | -------------------------------------------------------------------------------- /custom/keys/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nhost/hasura-backend-plus/2713d00ddf8480d2ff66cb83f22af8170b142edd/custom/keys/.gitkeep -------------------------------------------------------------------------------- /custom/storage-rules/rules.yaml: -------------------------------------------------------------------------------- 1 | functions: 2 | isAuthenticated: 'return !!request.auth' 3 | isOwner: "return !!request.auth && userId === request.auth['user-id']" 4 | validToken: 'return request.query.token === resource.Metadata.token' 5 | paths: 6 | /user/:userId/: 7 | list: 'isOwner(userId)' 8 | /user/:userId/:fileId: 9 | read: 'isOwner(userId) || validToken()' 10 | write: 'isOwner(userId)' 11 | /public*: 12 | read: 'true' 13 | write: 'true' 14 | -------------------------------------------------------------------------------- /docker/dev/.gitignore: -------------------------------------------------------------------------------- 1 | docker-compose.yaml 2 | db_data 3 | minio_data 4 | -------------------------------------------------------------------------------- /docker/dev/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14-alpine 2 | 3 | RUN apk update && apk upgrade && \ 4 | apk add --no-cache bash git openssh 5 | 6 | ARG NODE_ENV=development 7 | ENV NODE_ENV $NODE_ENV 8 | ENV PORT 3000 9 | 10 | ENV PGOPTIONS "-c search_path=auth" 11 | 12 | WORKDIR /app 13 | 14 | # needed for jest --watch to work properly 15 | RUN git init /app 16 | 17 | COPY package.json yarn.lock ./ 18 | RUN yarn install 19 | 20 | COPY ../../ . 21 | 22 | CMD ["yarn", "run", "dev:in-docker"] 23 | -------------------------------------------------------------------------------- /docker/dev/docker-compose-example.yaml: -------------------------------------------------------------------------------- 1 | version: '3.6' 2 | services: 3 | postgres: 4 | container_name: hbp-dev-postgres 5 | image: 'nhost/postgres:12-v0.0.6' 6 | restart: always 7 | volumes: 8 | - './db_data:/var/lib/postgresql/data' 9 | environment: 10 | POSTGRES_PASSWORD: pgpassword 11 | POSTGRES_DB: postgres 12 | graphql-engine: 13 | image: 'hasura/graphql-engine:v2.0.1' 14 | depends_on: 15 | - postgres 16 | restart: always 17 | ports: 18 | - '8080:8080' 19 | environment: 20 | HASURA_GRAPHQL_DATABASE_URL: >- 21 | postgres://postgres:pgpassword@postgres:5432/postgres 22 | HASURA_GRAPHQL_ENABLE_CONSOLE: 'true' 23 | HASURA_GRAPHQL_ADMIN_SECRET: hello123 24 | HASURA_GRAPHQL_JWT_SECRET: >- 25 | {"type":"HS256", "key": 26 | "jhyu89jiuhyg7678hoijhuytf7ghjiasodibagsdga9dha8os7df97a6sdgh9asudgo7f7g8h1uuoyafsod8pgasipdg8aps9dhaiaisydg8agsd87gasd9oihasd87gas78d"} 27 | HASURA_GRAPHQL_UNAUTHORIZED_ROLE: public 28 | command: 29 | - graphql-engine 30 | - serve 31 | hasura-backend-plus: 32 | image: 'nhost/hasura-backend-plus:latest' 33 | container_name: hbp-dev-hbp 34 | build: 35 | context: . 36 | dockerfile: Dockerfile.dev 37 | depends_on: 38 | - graphql-engine 39 | restart: always 40 | ports: 41 | - '4000:4000' 42 | environment: 43 | USER_IMPERSONATION_ENABLED: 'true' 44 | HOST: 0.0.0.0 45 | PORT: 4000 46 | DATABASE_URL: >- 47 | postgres://postgres:pgpassword@postgres:5432/postgres 48 | SERVER_URL: 'http://localhost:4000' 49 | HASURA_ENDPOINT: 'http://graphql-engine:8080/v1/graphql' 50 | HASURA_GRAPHQL_ADMIN_SECRET: hello123 51 | JWT_KEY: >- 52 | jhyu89jiuhyg7678hoijhuytf7ghjiasodibagsdga9dha8os7df97a6sdgh9asudgo7f7g8h1uuoyafsod8pgasipdg8aps9dhai;sd 53 | JWT_ALGORITHM: HS256 54 | ALLOWED_REDIRECT_URLS: 'http://localhost' 55 | JWT_CUSTOM_FIELDS: '' 56 | S3_ENDPOINT: 'minio:9000' 57 | S3_SSL_ENABLED: 'false' 58 | S3_BUCKET: nhost 59 | S3_ACCESS_KEY_ID: 5a7bdb5f42c41e0622bf61d6e08d5537 60 | S3_SECRET_ACCESS_KEY: 9e1c40c65a615a5b52f52aeeaf549944ec53acb1dff4a0bf01fb58e969f915c8 61 | AUTH_ENABLED: 'true' 62 | MAGIC_LINK_ENABLED: 'true' 63 | AUTH_LOCAL_USERS_ENABLED: 'true' 64 | AUTO_ACTIVATE_NEW_USERS: 'true' 65 | AUTH_ANONYMOUS_USERS_ACTIVE: 'false' 66 | PROVIDER_SUCCESS_REDIRECT: 'http://localhost:3001/success' 67 | PROVIDER_FAILURE_REDIRECT: 'http://localhost:3001/failed' 68 | ALLOW_USER_SELF_DELETE: 'false' 69 | LOST_PASSWORD_ENABLED: 'true' 70 | MIN_PASSWORD_LENGTH: 4 71 | CHANGE_EMAIL_ENABLED: 'true' 72 | VERIFY_EMAILS: 'true' 73 | NOTIFY_EMAIL_CHANGE: 'true' 74 | EMAILS_ENABLED: 'true' 75 | HIBP_ENABLED: 'false' 76 | DEFAULT_ALLOWED_USER_ROLES: 'user,me' 77 | ALLOWED_USER_ROLES: 'user,me' 78 | REGISTRATION_CUSTOM_FIELDS: 'display_name' 79 | COOKIE_SECURE: 'false' 80 | COOKIE_SECRET: 'somelongvalue' 81 | SMTP_HOST: mailhog 82 | SMTP_PORT: 1025 83 | SMTP_PASS: password 84 | SMTP_USER: user 85 | SMTP_SECURE: 'false' 86 | SMTP_SENDER: hbp@hbp.com 87 | REDIRECT_URL_SUCCESS: 'http://localhost:3000' 88 | REDIRECT_URL_ERROR: 'http://localhost:3000/fail' 89 | volumes: 90 | - '../../custom:/app/custom' 91 | - '../../src:/app/src' 92 | - '../../migrations:/app/migrations' 93 | minio: 94 | image: 'minio/minio:RELEASE.2020-06-18T02-23-35Z' 95 | container_name: hbp-dev-minio 96 | user: '999:1001' 97 | restart: always 98 | environment: 99 | MINIO_ACCESS_KEY: 5a7bdb5f42c41e0622bf61d6e08d5537 100 | MINIO_SECRET_KEY: 9e1c40c65a615a5b52f52aeeaf549944ec53acb1dff4a0bf01fb58e969f915c8 101 | entrypoint: sh 102 | command: -c 'mkdir -p /data/nhost && /usr/bin/minio server /data' 103 | ports: 104 | - '9000:9000' 105 | volumes: 106 | - './minio_data:/data' 107 | mailhog: 108 | image: mailhog/mailhog 109 | container_name: hbp-dev-mailhog 110 | environment: 111 | SMTP_HOST: mailhog 112 | SMTP_PORT: 1025 113 | SMTP_PASS: password 114 | SMTP_USER: user 115 | SMTP_SECURE: 'false' 116 | SMTP_SENDER: hbp@hbp.com 117 | ports: 118 | - 1025:1025 # smtp server 119 | - 8025:8025 # web ui 120 | -------------------------------------------------------------------------------- /docker/prod/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14-alpine AS builder 2 | WORKDIR /app 3 | COPY . . 4 | COPY package.json yarn.lock ./ 5 | RUN yarn install 6 | RUN yarn build 7 | 8 | FROM node:14-alpine 9 | ARG NODE_ENV=production 10 | ENV NODE_ENV $NODE_ENV 11 | ENV PORT 3000 12 | 13 | ENV PGOPTIONS "-c search_path=auth" 14 | 15 | WORKDIR /app 16 | 17 | COPY package.json . 18 | 19 | COPY --from=builder /app/dist dist 20 | COPY --from=builder /app/node_modules node_modules 21 | COPY custom custom 22 | COPY migrations migrations 23 | COPY prod-paths.js . 24 | 25 | HEALTHCHECK --interval=60s --timeout=2s --retries=3 CMD wget localhost:${PORT}/healthz -q -O - > /dev/null 2>&1 26 | 27 | EXPOSE $PORT 28 | CMD ["yarn", "start"] 29 | -------------------------------------------------------------------------------- /docker/test/docker-compose-test.yaml: -------------------------------------------------------------------------------- 1 | version: '3.6' 2 | services: 3 | postgres: 4 | container_name: hbp-dev-postgres 5 | image: 'nhost/postgres:12-v0.0.6' 6 | restart: always 7 | volumes: 8 | - './db_data:/var/lib/postgresql/data' 9 | environment: 10 | POSTGRES_PASSWORD: pgpassword 11 | POSTGRES_DB: postgres 12 | graphql-engine: 13 | image: 'hasura/graphql-engine:v2.0.1' 14 | depends_on: 15 | - postgres 16 | restart: always 17 | ports: 18 | - '8080:8080' 19 | environment: 20 | HASURA_GRAPHQL_DATABASE_URL: >- 21 | postgres://postgres:pgpassword@postgres:5432/postgres 22 | HASURA_GRAPHQL_ENABLE_CONSOLE: 'true' 23 | HASURA_GRAPHQL_ADMIN_SECRET: hello123 24 | HASURA_GRAPHQL_JWT_SECRET: >- 25 | {"type":"HS256", "key": 26 | "jhyu89jiuhyg7678hoijhuytf7ghjiasodibagsdga9dha8os7df97a6sdgh9asudgo7f7g8h1uuoyafsod8pgasipdg8aps9dhaiaisydg8agsd87gasd9oihasd87gas78d"} 27 | HASURA_GRAPHQL_UNAUTHORIZED_ROLE: public 28 | command: 29 | - graphql-engine 30 | - serve 31 | hasura-backend-plus: 32 | image: 'nhost/hasura-backend-plus:latest' 33 | container_name: hbp-dev-hbp 34 | build: 35 | context: ../../ 36 | dockerfile: Dockerfile.dev 37 | depends_on: 38 | - graphql-engine 39 | restart: always 40 | ports: 41 | - '4000:4000' 42 | environment: 43 | USER_IMPERSONATION_ENABLED: 'true' 44 | HOST: 0.0.0.0 45 | PORT: 4000 46 | DATABASE_URL: >- 47 | postgres://postgres:pgpassword@postgres:5432/postgres 48 | SERVER_URL: 'http://localhost:4000' 49 | HASURA_ENDPOINT: 'http://graphql-engine:8080/v1/graphql' 50 | HASURA_GRAPHQL_ADMIN_SECRET: hello123 51 | JWT_KEY: >- 52 | jhyu89jiuhyg7678hoijhuytf7ghjiasodibagsdga9dha8os7df97a6sdgh9asudgo7f7g8h1uuoyafsod8pgasipdg8aps9dhai;sd 53 | JWT_ALGORITHM: HS256 54 | ALLOWED_REDIRECT_URLS: 'htt://localhost' 55 | JWT_CUSTOM_FIELDS: 'name' 56 | S3_ENDPOINT: 'minio:9000' 57 | S3_SSL_ENABLED: 'false' 58 | S3_BUCKET: nhost 59 | S3_ACCESS_KEY_ID: 5a7bdb5f42c41e0622bf61d6e08d5537 60 | S3_SECRET_ACCESS_KEY: 9e1c40c65a615a5b52f52aeeaf549944ec53acb1dff4a0bf01fb58e969f915c8 61 | AUTH_ENABLED: 'true' 62 | MAGIC_LINK_ENABLED: 'true' 63 | AUTH_LOCAL_USERS_ENABLED: 'true' 64 | AUTO_ACTIVATE_NEW_USERS: 'true' 65 | AUTH_ANONYMOUS_USERS_ACTIVE: 'false' 66 | PROVIDER_SUCCESS_REDIRECT: 'http://localhost:3001/success' 67 | PROVIDER_FAILURE_REDIRECT: 'http://localhost:3001/failed' 68 | ALLOW_USER_SELF_DELETE: 'false' 69 | LOST_PASSWORD_ENABLED: 'true' 70 | MIN_PASSWORD_LENGTH: 4 71 | CHANGE_EMAIL_ENABLED: 'true' 72 | VERIFY_EMAILS: 'true' 73 | NOTIFY_EMAIL_CHANGE: 'true' 74 | EMAILS_ENABLED: 'true' 75 | HIBP_ENABLED: 'false' 76 | DEFAULT_ALLOWED_USER_ROLES: 'user,me' 77 | ALLOWED_USER_ROLES: 'user,me' 78 | REGISTRATION_CUSTOM_FIELDS: 'name' 79 | COOKIE_SECURE: 'false' 80 | COOKIE_SECRET: 'somelongvalue' 81 | SMTP_HOST: mailhog 82 | SMTP_PORT: 1025 83 | SMTP_PASS: password 84 | SMTP_USER: user 85 | SMTP_SECURE: 'false' 86 | SMTP_SENDER: hbp@hbp.com 87 | REDIRECT_URL_SUCCESS: 'http://localhost:3000' 88 | REDIRECT_URL_ERROR: 'http://localhost:3000/fail' 89 | volumes: 90 | - '../../custom:/app/custom' 91 | - '../../src:/app/src' 92 | - '/app/src/node_modules' 93 | - '../../package.json:/app/package.json' 94 | - '../../jest.config.js:/app/jest.config.js' 95 | - '../../migrations:/app/migrations' 96 | minio: 97 | image: 'minio/minio:RELEASE.2020-06-18T02-23-35Z' 98 | container_name: hbp-dev-minio 99 | user: '999:1001' 100 | restart: always 101 | environment: 102 | MINIO_ACCESS_KEY: 5a7bdb5f42c41e0622bf61d6e08d5537 103 | MINIO_SECRET_KEY: 9e1c40c65a615a5b52f52aeeaf549944ec53acb1dff4a0bf01fb58e969f915c8 104 | entrypoint: sh 105 | command: -c 'mkdir -p /data/nhost && /usr/bin/minio server /data' 106 | ports: 107 | - '9000:9000' 108 | volumes: 109 | - './minio_data:/data' 110 | mailhog: 111 | image: mailhog/mailhog 112 | container_name: hbp-dev-mailhog 113 | environment: 114 | SMTP_HOST: mailhog 115 | SMTP_PORT: 1025 116 | SMTP_PASS: password 117 | SMTP_USER: user 118 | SMTP_SECURE: 'false' 119 | SMTP_SENDER: hbp@hbp.com 120 | ports: 121 | - 1025:1025 # smtp server 122 | - 8025:8025 # web ui 123 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /docs/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator. 4 | 5 | ## Installation 6 | 7 | ```console 8 | yarn install 9 | ``` 10 | 11 | ## Local Development 12 | 13 | ```console 14 | yarn start 15 | ``` 16 | 17 | This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. 18 | 19 | ## Build 20 | 21 | ```console 22 | yarn build 23 | ``` 24 | 25 | This command generates static content into the `build` directory and can be served using any static contents hosting service. 26 | 27 | ## Deployment 28 | 29 | ```console 30 | GIT_USER= USE_SSH=true yarn deploy 31 | ``` 32 | 33 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. 34 | -------------------------------------------------------------------------------- /docs/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')], 3 | }; 4 | -------------------------------------------------------------------------------- /docs/docs/emails.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Emails 3 | --- 4 | 5 | Hasura Backend Plus can send transactional emails based that are normally used for authentication. 6 | 7 | [`EMAILS_ENABLED`](/docs/environment-variables#emails_enabled) must be `true` for emails to work. 8 | 9 | ## Activate Account 10 | 11 | The **Activate Account** email is sent to newly registered users if [`AUTO_ACTIVATE_NEW_USERS`](/docs/environment-variables#auto_activate_new_users) is `false`. 12 | 13 | Folder: `/custom/emails/activate-account/` 14 | 15 | ### Change Email 16 | 17 | The **Change Email** email is sent to a user requesting to change email if [`VERIFY_EMAILS`](/docs/environment-variables#verify_emails) is `true`. 18 | 19 | Folder: `/custom/emails/change-email/` 20 | 21 | ### Lost Password 22 | 23 | The **Lost Password** email is sent to a user requesting to set a new password if [`LOST_PASSWORD_ENABLED`](/docs/environment-variables#lost_password_enabled) is `true`. 24 | 25 | Folder: `/custom/emails/lost-password/` 26 | 27 | ### Magic Link 28 | 29 | The **Magic Link** email is sent to a user who register or login using the Magic Link login method if [`MAGIC_LINK_ENABLED`](/docs/environment-variables#magic_link_enabled) is `true`. 30 | 31 | Folder: `/custom/emails/magic-link/` 32 | 33 | ### Notify Email Change 34 | 35 | The **Notify Email Change** email is sent if a user change their email if [`NOTIFY_EMAIL_CHANGE`](/docs/environment-variables#notify_email_change) is `true`. 36 | 37 | Folder: `/custom/emails/notify-email-change/` 38 | 39 | ## SMTP Settings 40 | 41 | Sett all SMTP settings via [environment variables](/docs/environment-variables#email). 42 | -------------------------------------------------------------------------------- /docs/docs/getting-started/configuration.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Configuration 3 | --- 4 | 5 | You can change the configuration of Hasura Backend Plus by changing any environment variable in the `docker-compose.yaml` and then issue the following command: 6 | 7 | ```bash 8 | docker-compose up -d 9 | ``` 10 | 11 | See all [environment variables](/docs/environment-variables). 12 | -------------------------------------------------------------------------------- /docs/docs/getting-started/setup.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Setup 3 | --- 4 | 5 | Hasura Backend Plus runs in a container along side Postgres and Hasura. 6 | 7 | ## Nhost (recommended) 8 | 9 | The recommended way to start using Hasura Backend Plus is by using Nhost. 10 | 11 | With Nhost, you get a complete backend ready in seconds with Hasura, Hasura Backend Plus, Postgres and Minio. 12 | 13 | Go to [Nhost](https://nhost.io) and start building your app now. 14 | 15 | ## Self host 16 | 17 | Hasura Backend Plus is open source and can be self hosted. 18 | 19 | Here is a `docker-compose.yaml` file for the basic things you need to get started with Hasura Backend Plus. 20 | 21 | **Services:** 22 | 23 | - Postgres 24 | - Hasura 25 | - Hasura Backend Plus 26 | - Minio 27 | 28 | ```yaml title="docker-compose.yaml" 29 | version: "3.6" 30 | services: 31 | postgres: 32 | image: "nhost/postgres:12-v0.0.6" 33 | restart: always 34 | volumes: 35 | - "./db_data:/var/lib/postgresql/data" 36 | environment: 37 | POSTGRES_PASSWORD: pgpassword 38 | POSTGRES_DB: postgres 39 | graphql-engine: 40 | image: "hasura/graphql-engine:v2.0.1" 41 | depends_on: 42 | - postgres 43 | restart: always 44 | ports: 45 | - "8080:8080" 46 | environment: 47 | HASURA_GRAPHQL_DATABASE_URL: >- 48 | postgres://postgres:pgpassword@postgres:5432/postgres 49 | HASURA_GRAPHQL_ENABLE_CONSOLE: "true" 50 | HASURA_GRAPHQL_ADMIN_SECRET: hello123 51 | HASURA_GRAPHQL_JWT_SECRET: >- 52 | {"type":"HS256", "key": 53 | "jhyu89jiuhyg7678hoijhuytf7ghjiasodibagsdga9dha8os7df97a6sdgh9asudgo7f7g8h1uuoyafsod8pgasipdg8aps9dhaiaisydg8agsd87gasd9oihasd87gas78d"} 54 | HASURA_GRAPHQL_UNAUTHORIZED_ROLE: public 55 | command: 56 | - graphql-engine 57 | - serve 58 | hasura-backend-plus: 59 | image: "nhost/hasura-backend-plus:latest" 60 | container_name: hbp-dev-hbp 61 | depends_on: 62 | - graphql-engine 63 | restart: always 64 | ports: 65 | - "4000:4000" 66 | environment: 67 | HOST: 0.0.0.0 68 | PORT: 4000 69 | DATABASE_URL: >- 70 | postgres://postgres:pgpassword@postgres:5432/postgres 71 | SERVER_URL: "http://localhost:4000" 72 | HASURA_ENDPOINT: "http://graphql-engine:8080/v1/graphql" 73 | HASURA_GRAPHQL_ADMIN_SECRET: hello123 74 | JWT_KEY: >- 75 | jhyu89jiuhyg7678hoijhuytf7ghjiasodibagsdga9dha8os7df97a6sdgh9asudgo7f7g8h1uuoyafsod8pgasipdg8aps9dhaiaisydg8agsd87gasd9oihasd87gas78d 76 | JWT_ALGORITHM: HS256 77 | ALLOWED_REDIRECT_URLS: "http://localhost" 78 | JWT_CUSTOM_FIELDS: "" 79 | S3_ENDPOINT: "minio:9000" 80 | S3_SSL_ENABLED: "false" 81 | S3_BUCKET: nhost 82 | S3_ACCESS_KEY_ID: 5a7bdb5f42c41e0622bf61d6e08d5537 83 | S3_SECRET_ACCESS_KEY: 9e1c40c65a615a5b52f52aeeaf549944ec53acb1dff4a0bf01fb58e969f915c8 84 | AUTO_ACTIVATE_NEW_USERS: "true" 85 | PROVIDER_SUCCESS_REDIRECT: "http://localhost:3001/success" 86 | PROVIDER_FAILURE_REDIRECT: "http://localhost:3001/failed" 87 | HIBP_ENABLED: "false" 88 | DEFAULT_ALLOWED_USER_ROLES: "user,me" 89 | ALLOWED_USER_ROLES: "user,me" 90 | REGISTRATION_CUSTOM_FIELDS: "display_name" 91 | COOKIE_SECURE: "false" 92 | COOKIE_SECRET: "somelongvalue" 93 | REDIRECT_URL_SUCCESS: "http://localhost:3000" 94 | REDIRECT_URL_ERROR: "http://localhost:3000/fail" 95 | minio: 96 | image: "minio/minio:RELEASE.2020-06-18T02-23-35Z" 97 | container_name: hbp-dev-minio 98 | user: "999:1001" 99 | restart: always 100 | environment: 101 | MINIO_ACCESS_KEY: 5a7bdb5f42c41e0622bf61d6e08d5537 102 | MINIO_SECRET_KEY: 9e1c40c65a615a5b52f52aeeaf549944ec53acb1dff4a0bf01fb58e969f915c8 103 | entrypoint: sh 104 | command: -c 'mkdir -p /data/nhost && /usr/bin/minio server /data' 105 | ports: 106 | - "9000:9000" 107 | volumes: 108 | - "./minio_data:/data" 109 | ``` 110 | 111 | ## Start 112 | 113 | Start all services using: 114 | 115 | ```bash 116 | $ docker-compose up -d 117 | ``` 118 | 119 | ## Check logs 120 | 121 | Check all logs using: 122 | 123 | ```bash 124 | $ docker-compose logs -f 125 | ``` 126 | 127 | ## Startup Completed 128 | 129 | All services should now have started and Hasura Backend Plus have added tables in the `auth` schema and a `users` table in the `public` schema. 130 | 131 | You can see them in the hasura console running on [http://localhost:8080/console](http://localhost:8080/console). 132 | 133 | ## Register First User 134 | 135 | Add your first user: 136 | 137 | ```bash 138 | curl -d '{"email":"someone@nhost.io", "password":"StrongPasswordNot1234"}' -H "Content-Type: application/json" -X POST http://localhost:4000/auth/register 139 | ``` 140 | 141 | Hasura Backend Plus creates the user and responds with the JWT token plus some: 142 | 143 | ```json 144 | { 145 | "jwt_token": "eyJhbGciOiJIUzI1NiJ9.eyJodHRwczovL2hhc3VyYS5pby9qd3QvY2xhaW1zIjp7IngtaGFzdXJhLXVzZXItaWQiOiIxNzlhNjRkMS0wOTg5LTRmODEtOTU3Yi1mZTQ0MzQwYThhMDMiLCJ4LWhhc3VyYS1hbGxvd2VkLXJvbGVzIjpbInVzZXIiLCJtZSJdLCJ4LWhhc3VyYS1kZWZhdWx0LXJvbGUiOiJ1c2VyIn0sInN1YiI6IjE3OWE2NGQxLTA5ODktNGY4MS05NTdiLWZlNDQzNDBhOGEwMyIsImlzcyI6Im5ob3N0IiwiaWF0IjoxNjI2MzY1NDU1LCJleHAiOjE2MjYzNjYzNTV9.KN3Y7IzeWMoMAI6GxIbW0vI6CNL2SSjaH9IN0dD4058", 146 | "jwt_expires_in": 900000, 147 | "user": { 148 | "id": "179a64d1-0989-4f81-957b-fe44340a8a03", 149 | "display_name": "someone@nhost.io", 150 | "email": "someone@nhost.io" 151 | } 152 | } 153 | ``` 154 | 155 | > The registration endpoint returns the JWT token because the user was automatically activated. You can change this by setting `AUTO_ACTIVATE_NEW_USERS` to `false`. 156 | 157 | The user is also present in the `users` table in the [Hasura Console](http://localhost:8080/console/data/default/schema/public/tables/users/browse). 158 | 159 | ## Login User 160 | 161 | A user can be logged in by sending the same request to the `/auth/login` endpoint. 162 | 163 | ```bash 164 | curl -d '{"email":"someone@nhost.io", "password":"StrongPasswordNot1234"}' -H "Content-Type: application/json" -X POST http://localhost:4000/auth/login 165 | ``` 166 | -------------------------------------------------------------------------------- /docs/docs/intro.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Introduction 3 | --- 4 | 5 | Hasura Backend Plus handles **authentication** and **storage** for [Hasura](https://github.com/hasura/graphql-engine). 6 | 7 | Hasura Backend Plus runs in a separate Docker container along side Postgres and Hasura. 8 | 9 | ## Authentication 10 | 11 | - Users and accounts are saved in the database. 12 | - JWT tokens and refresh tokens are automatically generated and managed. 13 | - Add custom user claims to the JWT token based on user data. 14 | - Hasura roles managed. 15 | - Two-factor authentication support. 16 | - Third-party OAuth providers such as GitHub, Google, Facebook, Twitter etc. 17 | - Magic Link support. 18 | - Built in transactional emails such as account activation and password reset. 19 | - Rate limiting. 20 | - Optional checking for [Pwned Passwords](https://haveibeenpwned.com/Passwords). 21 | 22 | ## Storage 23 | 24 | - Backed by S3 (Minio). 25 | - Rules engine for file access permissions. 26 | - Out of the box image transformation. 27 | -------------------------------------------------------------------------------- /docs/docs/oauth-providers.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: OAuth Providers 3 | --- 4 | 5 | Hasura Backend Plus makes it easy to sign in users using external OAuth provides. 6 | 7 | The following OAuth providers are supported: 8 | 9 | - GitHub 10 | - Google 11 | - Facebook 12 | - Apple 13 | - Twitter 14 | - LinkedIn 15 | - Spotify 16 | - Windows Live 17 | 18 | When a user register using an OAuth provider Hasura Backend Plus creates the user and account in the local database. 19 | 20 | `email`, `display_name` and `avatar_url` will automatically be used from the externa OAuth provider for the user in the database. 21 | -------------------------------------------------------------------------------- /docs/docusaurus.config.js: -------------------------------------------------------------------------------- 1 | const lightCodeTheme = require("prism-react-renderer/themes/github"); 2 | const darkCodeTheme = require("prism-react-renderer/themes/dracula"); 3 | 4 | /** @type {import('@docusaurus/types').DocusaurusConfig} */ 5 | module.exports = { 6 | title: "Hasura Backend Plus", 7 | tagline: "Authentication and Storage for Hasura", 8 | url: "https://nhost.github.io", 9 | baseUrl: "/hasura-backend-plus/", 10 | trailingSlash: false, 11 | onBrokenLinks: "throw", 12 | onBrokenMarkdownLinks: "warn", 13 | favicon: "img/favicon.ico", 14 | organizationName: "nhost", // Usually your GitHub org/user name. 15 | projectName: "hasura-backend-plus", // Usually your repo name. 16 | themeConfig: { 17 | colorMode: { 18 | defaultMode: "light", 19 | }, 20 | announcementBar: { 21 | id: "announcementBar-1", // Increment on change 22 | content: ` 23 | Deploy Hasura Backend Plus (+ Postgres and Hasura) in seconds with Nhost! 24 | `, 25 | backgroundColor: "#fafbfc", 26 | textColor: "#091E42", 27 | isCloseable: false, 28 | }, 29 | navbar: { 30 | title: "Hasura Backend Plus", 31 | logo: { 32 | alt: "Hasura Backend Plus Documentation", 33 | src: "img/logo.png", 34 | }, 35 | items: [ 36 | { 37 | type: "doc", 38 | docId: "intro", 39 | position: "left", 40 | label: "Documentation", 41 | }, 42 | { 43 | type: "doc", 44 | docId: "api-reference", 45 | position: "left", 46 | label: "API Reference", 47 | }, 48 | { 49 | href: "https://github.com/nhost/hasura-backend-plus", 50 | label: "GitHub", 51 | position: "right", 52 | }, 53 | ], 54 | }, 55 | footer: { 56 | style: "dark", 57 | links: [ 58 | { 59 | title: "Docs", 60 | items: [ 61 | { 62 | label: "Tutorial", 63 | to: "/docs/intro", 64 | }, 65 | ], 66 | }, 67 | { 68 | title: "Community", 69 | items: [ 70 | { 71 | label: "Discord", 72 | href: "https://discord.com/invite/9V7Qb2U", 73 | }, 74 | { 75 | label: "Twitter", 76 | href: "https://twitter.com/nhostio", 77 | }, 78 | ], 79 | }, 80 | { 81 | title: "More", 82 | items: [ 83 | { 84 | label: "Nhost", 85 | to: "https://nhost.io", 86 | }, 87 | { 88 | label: "GitHub", 89 | href: "https://github.com/nhost/hasura-backend-plus", 90 | }, 91 | ], 92 | }, 93 | ], 94 | copyright: `Open Source ${new Date().getFullYear()} Nhost AB, Inc. Built with Docusaurus.`, 95 | }, 96 | prism: { 97 | theme: lightCodeTheme, 98 | darkTheme: darkCodeTheme, 99 | }, 100 | }, 101 | presets: [ 102 | [ 103 | "@docusaurus/preset-classic", 104 | { 105 | docs: { 106 | sidebarPath: require.resolve("./sidebars.js"), 107 | editUrl: 108 | "https://github.com/nhost/hasura-backend-plus/edit/master/docs/", 109 | }, 110 | theme: { 111 | customCss: require.resolve("./src/css/custom.css"), 112 | }, 113 | }, 114 | ], 115 | ], 116 | }; 117 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "docusaurus start", 8 | "build": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "docusaurus deploy", 11 | "clear": "docusaurus clear", 12 | "serve": "docusaurus serve", 13 | "write-translations": "docusaurus write-translations", 14 | "write-heading-ids": "docusaurus write-heading-ids" 15 | }, 16 | "dependencies": { 17 | "@docusaurus/core": "2.0.0-beta.3", 18 | "@docusaurus/preset-classic": "2.0.0-beta.3", 19 | "@mdx-js/react": "^1.6.22", 20 | "@svgr/webpack": "^5.5.0", 21 | "clsx": "^1.1.1", 22 | "file-loader": "^6.2.0", 23 | "prism-react-renderer": "^1.2.1", 24 | "react": "^17.0.1", 25 | "react-dom": "^17.0.1", 26 | "url-loader": "^4.1.1" 27 | }, 28 | "browserslist": { 29 | "production": [ 30 | ">0.5%", 31 | "not dead", 32 | "not op_mini all" 33 | ], 34 | "development": [ 35 | "last 1 chrome version", 36 | "last 1 firefox version", 37 | "last 1 safari version" 38 | ] 39 | }, 40 | "devDependencies": { 41 | "@docusaurus/module-type-aliases": "^2.0.0-beta.3", 42 | "@tsconfig/docusaurus": "^1.0.2", 43 | "@types/react": "^17.0.14", 44 | "@types/react-helmet": "^6.1.2", 45 | "@types/react-router-dom": "^5.1.8", 46 | "typescript": "^4.3.5" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /docs/sidebars.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creating a sidebar enables you to: 3 | - create an ordered group of docs 4 | - render a sidebar for each doc of that group 5 | - provide next/previous navigation 6 | 7 | The sidebars can be generated from the filesystem, or explicitly defined here. 8 | 9 | Create as many sidebars as you want. 10 | */ 11 | 12 | module.exports = { 13 | // By default, Docusaurus generates a sidebar from the docs folder structure 14 | // tutorialSidebar: [{type: 'autogenerated', dirName: '.'}], 15 | 16 | // But you can create a sidebar manually 17 | tutorialSidebar: [ 18 | "intro", 19 | { 20 | type: "category", 21 | label: "Getting Started", 22 | items: ["getting-started/setup", "getting-started/configuration"], 23 | }, 24 | "oauth-providers", 25 | "storage-rules", 26 | "emails", 27 | "environment-variables", 28 | ], 29 | }; 30 | -------------------------------------------------------------------------------- /docs/src/components/HomepageFeatures.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import clsx from "clsx"; 3 | import styles from "./HomepageFeatures.module.css"; 4 | 5 | const FeatureList = [ 6 | { 7 | title: "Hasura", 8 | Svg: require("../../static/img/programming.svg").default, 9 | description: ( 10 | <> 11 | Works alongside with Hasura GraphQL Engine and seamlessly integrates the 12 | recurrent features you're craving for. 13 | 14 | ), 15 | }, 16 | { 17 | title: "Authentication", 18 | Svg: require("../../static/img/authentication.svg").default, 19 | description: ( 20 | <> 21 | Comprehensive user accounts management, JWT, optional multi-factor 22 | authentication, Hasura claims with roles and custom fields and many 23 | more. 24 | 25 | ), 26 | }, 27 | { 28 | title: "Storage", 29 | Svg: require("../../static/img/storage.svg").default, 30 | description: ( 31 | <> 32 | Easy and configurable API for any S3-compatible object storage such as 33 | Minio. 34 | 35 | ), 36 | }, 37 | ]; 38 | 39 | function Feature({ Svg, title, description }) { 40 | return ( 41 |
42 |
43 | 44 |
45 |
46 |

{title}

47 |

{description}

48 |
49 |
50 | ); 51 | } 52 | 53 | export default function HomepageFeatures() { 54 | return ( 55 |
56 |
57 |
58 | {FeatureList.map((props, idx) => ( 59 | 60 | ))} 61 |
62 |
63 |
64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /docs/src/components/HomepageFeatures.module.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable docusaurus/copyright-header */ 2 | 3 | .features { 4 | display: flex; 5 | align-items: center; 6 | padding: 2rem 0; 7 | width: 100%; 8 | } 9 | 10 | .featureSvg { 11 | height: 200px; 12 | width: 200px; 13 | } 14 | -------------------------------------------------------------------------------- /docs/src/css/custom.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable docusaurus/copyright-header */ 2 | /** 3 | * Any CSS included here will be global. The classic template 4 | * bundles Infima by default. Infima is a CSS framework designed to 5 | * work well for content-centric websites. 6 | */ 7 | 8 | /* You can override the default Infima variables here. */ 9 | :root { 10 | --ifm-color-primary: #0099ff; 11 | --ifm-color-primary-dark: #008ae6; 12 | --ifm-color-primary-darker: #0082d9; 13 | --ifm-color-primary-darkest: #006bb3; 14 | --ifm-color-primary-light: #1aa3ff; 15 | --ifm-color-primary-lighter: #26a8ff; 16 | --ifm-color-primary-lightest: #4db8ff; 17 | --ifm-code-font-size: 95%; 18 | } 19 | 20 | .docusaurus-highlight-code-line { 21 | background-color: rgba(0, 0, 0, 0.1); 22 | display: block; 23 | margin: 0 calc(-1 * var(--ifm-pre-padding)); 24 | padding: 0 var(--ifm-pre-padding); 25 | } 26 | 27 | html[data-theme="dark"] .docusaurus-highlight-code-line { 28 | background-color: rgba(0, 0, 0, 0.3); 29 | } 30 | -------------------------------------------------------------------------------- /docs/src/pages/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import clsx from "clsx"; 3 | import Layout from "@theme/Layout"; 4 | import Link from "@docusaurus/Link"; 5 | import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; 6 | import styles from "./index.module.css"; 7 | import HomepageFeatures from "../components/HomepageFeatures"; 8 | 9 | function HomepageHeader() { 10 | const { siteConfig } = useDocusaurusContext(); 11 | return ( 12 |
13 |
14 | 15 |

{siteConfig.title}

16 |

{siteConfig.tagline}

17 |
18 | 22 | Get started 23 | 24 |
25 |
26 |
27 | ); 28 | } 29 | 30 | export default function Home() { 31 | const { siteConfig } = useDocusaurusContext(); 32 | return ( 33 | 37 | 38 |
39 | 40 |
41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /docs/src/pages/index.module.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable docusaurus/copyright-header */ 2 | 3 | /** 4 | * CSS files with the .module.css suffix will be treated as CSS modules 5 | * and scoped locally. 6 | */ 7 | 8 | .heroBanner { 9 | padding: 4rem 0; 10 | text-align: center; 11 | position: relative; 12 | overflow: hidden; 13 | } 14 | 15 | @media screen and (max-width: 966px) { 16 | .heroBanner { 17 | padding: 2rem; 18 | } 19 | } 20 | 21 | .buttons { 22 | display: flex; 23 | align-items: center; 24 | justify-content: center; 25 | } 26 | -------------------------------------------------------------------------------- /docs/src/pages/markdown-page.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Markdown page example 3 | --- 4 | 5 | # Markdown page example 6 | 7 | You don't need React to write simple standalone pages. 8 | -------------------------------------------------------------------------------- /docs/static/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nhost/hasura-backend-plus/2713d00ddf8480d2ff66cb83f22af8170b142edd/docs/static/.nojekyll -------------------------------------------------------------------------------- /docs/static/img/docusaurus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nhost/hasura-backend-plus/2713d00ddf8480d2ff66cb83f22af8170b142edd/docs/static/img/docusaurus.png -------------------------------------------------------------------------------- /docs/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nhost/hasura-backend-plus/2713d00ddf8480d2ff66cb83f22af8170b142edd/docs/static/img/favicon.ico -------------------------------------------------------------------------------- /docs/static/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nhost/hasura-backend-plus/2713d00ddf8480d2ff66cb83f22af8170b142edd/docs/static/img/logo.png -------------------------------------------------------------------------------- /docs/static/img/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/static/img/tutorial/docsVersionDropdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nhost/hasura-backend-plus/2713d00ddf8480d2ff66cb83f22af8170b142edd/docs/static/img/tutorial/docsVersionDropdown.png -------------------------------------------------------------------------------- /docs/static/img/tutorial/localeDropdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nhost/hasura-backend-plus/2713d00ddf8480d2ff66cb83f22af8170b142edd/docs/static/img/tutorial/localeDropdown.png -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { "extends": "@tsconfig/docusaurus/tsconfig.json", "include": ["src/"] } 2 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | globalSetup: '/src/test/global-setup.ts', 3 | verbose: true, 4 | moduleNameMapper: { 5 | '^@shared/(.*)$': '/src/shared/$1', 6 | '^@test/(.*)$': '/src/test/$1' 7 | }, 8 | testPathIgnorePatterns: ['/dist/', '/node_modules/'], 9 | setupFilesAfterEnv: ['jest-extended', '/src/test/setup.ts'], 10 | // transform: { 11 | // '^.+\\.ts?$': 'ts-jest' 12 | // } 13 | preset: 'ts-jest' 14 | } 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hasura-backend-plus", 3 | "license": "MIT", 4 | "repository": { 5 | "url": "git://github.com/nhost/hasura-backend-plus.git", 6 | "type": "git" 7 | }, 8 | "version": "2.3.0", 9 | "main": "src/start.ts", 10 | "scripts": { 11 | "test": "jest", 12 | "test:in-docker": "cd docker/dev; docker exec -it hbp-dev-hbp yarn test --runInBand", 13 | "dev:docker:logs": "cd docker/dev; docker-compose logs -f", 14 | "dev:docker:down": "cd docker/dev; docker-compose down", 15 | "dev:docker:up": "cd docker/dev; docker-compose up", 16 | "dev": "yarn dev:docker:up", 17 | "dev:in-docker": "ts-node-dev -r tsconfig-paths/register --exit-child --respawn --poll --unhandled-rejections=strict src/ts-start.ts", 18 | "report-coverage": "codecov", 19 | "docs:build": "vuepress build docs", 20 | "docs:dev": "vuepress dev docs", 21 | "build": "tsc", 22 | "start": "node -r ./dist/start.js", 23 | "lint": "eslint --ext .ts .", 24 | "lint:fix": "npm run lint --fix" 25 | }, 26 | "config": { 27 | "commitizen": { 28 | "path": "./node_modules/cz-conventional-changelog" 29 | } 30 | }, 31 | "dependencies": { 32 | "@hapi/joi": "17.1.1", 33 | "@nicokaiser/passport-apple": "^0.2.1", 34 | "@types/sharp": "^0.27.1", 35 | "archiver": "4.0.1", 36 | "aws-sdk": "2.656.0", 37 | "axios": "^0.21.1", 38 | "bcryptjs": "^2.4.3", 39 | "body-parser": "1.19.0", 40 | "cookie-parser": "1.4.5", 41 | "cors": "2.8.5", 42 | "dotenv": "8.2.0", 43 | "ejs": "3.0.2", 44 | "email-templates": "7.0.4", 45 | "express": "4.17.1", 46 | "express-boom": "^3.0.0", 47 | "express-fileupload": "1.1.9", 48 | "express-rate-limit": "5.1.1", 49 | "express-session": "^1.17.1", 50 | "fs-extra": "^9.0.0", 51 | "graphql": "15.0.0", 52 | "graphql-request": "1.8.2", 53 | "graphql-tag": "2.11.0", 54 | "helmet": "3.22.0", 55 | "hibp": "9.0.0", 56 | "jose": "1.28.1", 57 | "js-yaml": "3.13.1", 58 | "lodash.kebabcase": "4.1.1", 59 | "module-alias": "^2.2.2", 60 | "morgan": "^1.10.0", 61 | "nocache": "^2.1.0", 62 | "node-fetch": "^2.6.1", 63 | "nodemailer": "6.4.16", 64 | "notevil": "1.3.3", 65 | "otplib": "12.0.1", 66 | "passport": "0.4.1", 67 | "passport-facebook": "^3.0.0", 68 | "passport-github2": "0.1.12", 69 | "passport-google-oauth20": "2.0.0", 70 | "passport-linkedin-oauth2": "^2.0.0", 71 | "passport-spotify": "^2.0.0", 72 | "passport-twitter": "^1.0.4", 73 | "passport-windowslive": "^1.0.2", 74 | "path-exists-cli": "^1.0.0", 75 | "pg": "^8.6.0", 76 | "postgres-migrations": "^5.1.1", 77 | "qrcode": "1.4.4", 78 | "sharp": "^0.27.0", 79 | "temp-dir": "^2.0.0", 80 | "uuid": "7.0.3" 81 | }, 82 | "devDependencies": { 83 | "@types/archiver": "3.1.0", 84 | "@types/bcryptjs": "^2.4.2", 85 | "@types/body-parser": "1.19.0", 86 | "@types/cookie-parser": "1.4.2", 87 | "@types/cors": "2.8.6", 88 | "@types/death": "^1.1.1", 89 | "@types/ejs": "3.0.2", 90 | "@types/email-templates": "7.0.1", 91 | "@types/express": "4.17.6", 92 | "@types/express-boom": "^3.0.0", 93 | "@types/express-fileupload": "1.1.3", 94 | "@types/express-rate-limit": "5.0.0", 95 | "@types/express-session": "^1.17.0", 96 | "@types/fs-extra": "^8.1.0", 97 | "@types/hapi__joi": "16.0.12", 98 | "@types/helmet": "0.0.45", 99 | "@types/jest": "^26.0.24", 100 | "@types/js-yaml": "3.12.3", 101 | "@types/lodash.kebabcase": "4.1.6", 102 | "@types/morgan": "^1.9.0", 103 | "@types/node": "^13.11.1", 104 | "@types/node-fetch": "^2.5.7", 105 | "@types/nodemailer": "6.4.0", 106 | "@types/passport": "1.0.3", 107 | "@types/passport-facebook": "^2.1.9", 108 | "@types/passport-github2": "1.2.4", 109 | "@types/passport-google-oauth20": "2.0.3", 110 | "@types/passport-linkedin-oauth2": "^1.5.1", 111 | "@types/passport-spotify": "^1.1.0", 112 | "@types/passport-twitter": "^1.0.34", 113 | "@types/pg": "^7.14.11", 114 | "@types/qrcode": "1.3.4", 115 | "@types/supertest": "^2.0.8", 116 | "@types/temp-dir": "^2.0.2", 117 | "@types/uuid": "7.0.2", 118 | "@typescript-eslint/eslint-plugin": "2.28.0", 119 | "@typescript-eslint/parser": "2.28.0", 120 | "@vuepress/plugin-back-to-top": "1.4.1", 121 | "codecov": "^3.7.1", 122 | "cz-conventional-changelog": "3.1.0", 123 | "dotenv-cli": "^4.0.0", 124 | "eslint": "6.8.0", 125 | "eslint-config-prettier": "6.10.1", 126 | "eslint-plugin-jest": "23.8.2", 127 | "eslint-plugin-prettier": "3.1.3", 128 | "get-port": "^5.1.1", 129 | "husky": "4.2.5", 130 | "jest": "^27.0.6", 131 | "jest-extended": "0.11.5", 132 | "lint-staged": "10.1.3", 133 | "markdown-it-multimd-table": "4.0.1", 134 | "npm-run-all": "^4.1.5", 135 | "prettier": "2.0.4", 136 | "pretty-quick": "2.0.1", 137 | "supertest": "4.0.2", 138 | "ts-jest": "^27.0.3", 139 | "ts-node": "8.8.2", 140 | "ts-node-dev": "^1.1.6", 141 | "tsconfig-paths": "3.9.0", 142 | "typescript": "3.8.3", 143 | "vuepress": "^1.8.2" 144 | }, 145 | "_moduleAliases": { 146 | "@shared": "./dist/shared", 147 | "@test": "./dist/test" 148 | }, 149 | "husky": { 150 | "hooks": { 151 | "pre-commit": "lint-staged" 152 | } 153 | }, 154 | "lint-staged": { 155 | "*.js,*.ts,*.md,*.json": [ 156 | "eslint --fix", 157 | "prettier --write" 158 | ] 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /prod-paths.js: -------------------------------------------------------------------------------- 1 | const tsConfig = require('./tsconfig.json') 2 | const tsConfigPaths = require('tsconfig-paths') 3 | 4 | const baseUrl = './dist' // Either absolute or relative path. If relative it's resolved to current working directory. 5 | const cleanup = tsConfigPaths.register({ 6 | baseUrl, 7 | paths: tsConfig.compilerOptions.paths 8 | }) 9 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Response } from 'express' 2 | import { RequestExtended } from '@shared/types' 3 | 4 | interface Error { 5 | output?: { 6 | payload?: Record 7 | statusCode?: number 8 | } 9 | details?: [ 10 | { 11 | message?: string 12 | } 13 | ] 14 | } 15 | 16 | /** 17 | * This is a custom error middleware for Express. 18 | * https://expressjs.com/en/guide/error-handling.html 19 | */ 20 | export async function errors( 21 | err: Error, 22 | _req: RequestExtended, 23 | res: Response, 24 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 25 | next: NextFunction 26 | ): Promise { 27 | const code = err?.output?.statusCode || 400 28 | 29 | // log error 30 | console.error(err) 31 | 32 | /** 33 | * The default error message looks like this. 34 | */ 35 | const error = err?.output?.payload || { 36 | statusCode: code, 37 | error: code === 400 ? 'Bad Request' : 'Internal Server Error', 38 | message: err?.details?.[0]?.message 39 | } 40 | 41 | return res.status(code).send({ ...error }) 42 | } 43 | -------------------------------------------------------------------------------- /src/limiter.ts: -------------------------------------------------------------------------------- 1 | import { APPLICATION } from '@shared/config' 2 | import rateLimit, { Message } from 'express-rate-limit' 3 | 4 | /** 5 | * In order to stay consistent with the error message 6 | * format used in `src/utils/errors.ts`, the `Message` 7 | * interface from `express-rate-limit` is extended to 8 | * include the `statusCode` property. 9 | */ 10 | interface LimitMessage extends Message { 11 | statusCode: number 12 | message: string 13 | [key: string]: unknown 14 | } 15 | 16 | export const limiter = rateLimit({ 17 | headers: true, 18 | 19 | max: APPLICATION.MAX_REQUESTS, 20 | windowMs: APPLICATION.TIME_FRAME, 21 | skip: ({ path }) => { 22 | // Don't limit health checks. See https://github.com/nhost/hasura-backend-plus/issues/175 23 | if (path === '/healthz') return true 24 | return false 25 | }, 26 | /** 27 | * To use the above created interface, an `unknown` 28 | * conversion for non-overlapping types is necessary. 29 | */ 30 | message: ({ 31 | statusCode: 429, 32 | error: 'Too Many Requests', 33 | message: 'You are being rate limited.' 34 | } as unknown) as LimitMessage 35 | }) 36 | -------------------------------------------------------------------------------- /src/middlewares/auth.ts: -------------------------------------------------------------------------------- 1 | import { Response, NextFunction } from 'express' 2 | import { COOKIES } from '@shared/config' 3 | import { RefreshTokenMiddleware, RequestExtended, PermissionVariables, Claims } from '@shared/types' 4 | import { getClaims } from '@shared/jwt' 5 | import { getPermissionVariablesFromCookie } from '@shared/helpers' 6 | 7 | export function authMiddleware(req: RequestExtended, res: Response, next: NextFunction) { 8 | let refresh_token = { 9 | value: null, 10 | type: null 11 | } as RefreshTokenMiddleware 12 | // let permission_variables = {}; 13 | 14 | // check for Authorization header 15 | let claimsInBody = false 16 | let claims: Claims | null = null 17 | 18 | try { 19 | claims = getClaims(req.headers.authorization) 20 | claimsInBody = true 21 | } catch (e) { 22 | // noop 23 | } 24 | 25 | if (claimsInBody && claims) { 26 | // remove `x-hasura-` from claim props 27 | const claims_sanatized: { [k: string]: any } = {} 28 | for (const claimKey in claims) { 29 | claims_sanatized[claimKey.replace('x-hasura-', '') as string] = claims[claimKey] 30 | } 31 | 32 | req.permission_variables = claims_sanatized as PermissionVariables 33 | } 34 | 35 | // check for refresh token in body? 36 | if ('refresh_token' in req.query) { 37 | refresh_token = { 38 | value: req.query.refresh_token as string, 39 | type: 'query' 40 | } 41 | req.refresh_token = refresh_token 42 | } 43 | 44 | // ------------------------------------- 45 | // COOKIES 46 | // ------------------------------------- 47 | const cookiesInUse = COOKIES.SECRET ? req.signedCookies : req.cookies 48 | 49 | if ('refresh_token' in cookiesInUse) { 50 | refresh_token = { 51 | value: cookiesInUse.refresh_token, 52 | type: 'cookie' 53 | } 54 | req.refresh_token = refresh_token 55 | } 56 | 57 | if ('permission_variables' in cookiesInUse) { 58 | try { 59 | req.permission_variables = getPermissionVariablesFromCookie(req) 60 | } catch (err) { 61 | console.error(err) 62 | return res.boom.unauthorized(err) 63 | } 64 | } 65 | 66 | next() 67 | } 68 | -------------------------------------------------------------------------------- /src/routes/auth/activate.ts: -------------------------------------------------------------------------------- 1 | import { APPLICATION } from '@shared/config' 2 | import { Request, Response } from 'express' 3 | 4 | import { activateAccount } from '@shared/queries' 5 | import { asyncWrapper, getEndURLOperator } from '@shared/helpers' 6 | import { request } from '@shared/request' 7 | import { v4 as uuidv4 } from 'uuid' 8 | import { verifySchema } from '@shared/validation' 9 | import { UpdateAccountData } from '@shared/types' 10 | import { setRefreshToken } from '@shared/cookies' 11 | 12 | async function activateUser({ query }: Request, res: Response): Promise { 13 | if (!APPLICATION.REDIRECT_URL_SUCCESS) { 14 | return res.boom.badImplementation( 15 | 'Environment variable REDIRECT_URL_SUCCESS must be set for activation to work.' 16 | ) 17 | } 18 | 19 | let hasuraData: UpdateAccountData 20 | const useCookie = typeof query.cookie !== 'undefined' ? query.cookie === 'true' : true 21 | 22 | const { ticket } = await verifySchema.validateAsync(query) 23 | 24 | const new_ticket = uuidv4() 25 | 26 | try { 27 | hasuraData = await request(activateAccount, { 28 | ticket, 29 | new_ticket, 30 | now: new Date() 31 | }) 32 | } catch (err) /* istanbul ignore next */ { 33 | console.error(err) 34 | if (APPLICATION.REDIRECT_URL_ERROR) { 35 | return res.redirect(302, APPLICATION.REDIRECT_URL_ERROR) 36 | } 37 | throw err 38 | } 39 | 40 | const { affected_rows } = hasuraData.update_auth_accounts 41 | 42 | if (!affected_rows) { 43 | console.error('Invalid or expired ticket') 44 | 45 | if (APPLICATION.REDIRECT_URL_ERROR) { 46 | return res.redirect(302, APPLICATION.REDIRECT_URL_ERROR) 47 | } 48 | /* istanbul ignore next */ 49 | return res.boom.unauthorized('Invalid or expired ticket.') 50 | } 51 | 52 | const refresh_token = await setRefreshToken( 53 | res, 54 | hasuraData.update_auth_accounts.returning[0].id, 55 | useCookie 56 | ) 57 | 58 | const url_operator = getEndURLOperator({ 59 | url: APPLICATION.REDIRECT_URL_SUCCESS 60 | }) 61 | 62 | // Redirect user with refresh token. 63 | // This is both for when users log in and register. 64 | return res.redirect( 65 | `${APPLICATION.REDIRECT_URL_SUCCESS}${url_operator}refresh_token=${refresh_token}` 66 | ) 67 | } 68 | 69 | export default asyncWrapper(activateUser) 70 | -------------------------------------------------------------------------------- /src/routes/auth/change-email/direct-change.ts: -------------------------------------------------------------------------------- 1 | import { Response } from 'express' 2 | 3 | import { asyncWrapper } from '@shared/helpers' 4 | import { changeEmailByUserId } from '@shared/queries' 5 | import { request } from '@shared/request' 6 | 7 | import { getRequestInfo } from './utils' 8 | import { RequestExtended } from '@shared/types' 9 | import { AUTHENTICATION } from '@shared/config' 10 | 11 | async function requestChangeEmail(req: RequestExtended, res: Response): Promise { 12 | if(AUTHENTICATION.VERIFY_EMAILS) { 13 | return res.boom.badImplementation(`Please set the VERIFY_EMAILS env variable to false to use the auth/change-email route.`) 14 | } 15 | 16 | const { user_id, new_email } = await getRequestInfo(req, res) 17 | 18 | // * Email verification is not activated - change email straight away 19 | await request(changeEmailByUserId, { user_id, new_email }) 20 | 21 | return res.status(204).send() 22 | } 23 | 24 | export default asyncWrapper(requestChangeEmail) 25 | -------------------------------------------------------------------------------- /src/routes/auth/change-email/email.test.ts: -------------------------------------------------------------------------------- 1 | import 'jest-extended' 2 | 3 | import { 4 | mailHogSearch, 5 | deleteMailHogEmail, 6 | generateRandomEmail, 7 | withEnv, 8 | registerAccount 9 | } from '@test/utils' 10 | import { registerAndLoginAccount } from '@test/utils' 11 | 12 | import { request } from '@test/server' 13 | import { end } from '@test/supertest-shared-utils' 14 | 15 | it('should request to change email', (done) => { 16 | registerAccount(request).then(() => { 17 | request 18 | .post('/auth/change-email/request') 19 | .send({ new_email: generateRandomEmail() }) 20 | .expect(204) 21 | .end(end(done)) 22 | }) 23 | }) 24 | 25 | it('should receive a ticket by email', (done) => { 26 | withEnv( 27 | { 28 | EMAILS_ENABLED: 'true', 29 | VERIFY_EMAILS: 'true' 30 | }, 31 | request, 32 | async () => { 33 | await registerAccount(request).then(() => { 34 | const new_email = generateRandomEmail() 35 | 36 | request 37 | .post('/auth/change-email/request') 38 | .send({ new_email }) 39 | .expect(204) 40 | .end(async (err) => { 41 | if (err) return done(err) 42 | const [message] = await mailHogSearch(new_email) 43 | expect(message).toBeTruthy() 44 | expect(message.Content.Headers.Subject).toInclude('Change your email address') 45 | expect(message.Content.Headers['X-Ticket'][0]).toBeString() 46 | await deleteMailHogEmail(message) 47 | done() 48 | }) 49 | }) 50 | } 51 | ) 52 | }) 53 | 54 | it('should change the email from a ticket', (done) => { 55 | withEnv( 56 | { 57 | EMAILS_ENABLED: 'true', 58 | VERIFY_EMAILS: 'true' 59 | }, 60 | request, 61 | async () => { 62 | await registerAndLoginAccount(request).then(() => { 63 | const new_email = generateRandomEmail() 64 | 65 | request 66 | .post('/auth/change-email/request') 67 | .send({ new_email }) 68 | .expect(204) 69 | .end(async (err) => { 70 | if (err) return done(err) 71 | const [message] = await mailHogSearch(new_email) 72 | expect(message).toBeTruthy() 73 | expect(message.Content.Headers.Subject).toInclude('Change your email address') 74 | const ticket = message.Content.Headers['X-Ticket'][0] 75 | expect(ticket).toBeString() 76 | await deleteMailHogEmail(message) 77 | 78 | request.post('/auth/change-email/change').send({ ticket }).expect(204).end(end(done)) 79 | }) 80 | }) 81 | } 82 | ) 83 | }) 84 | 85 | it('should reconnect using the new email', (done) => { 86 | withEnv( 87 | { 88 | EMAILS_ENABLED: 'true', 89 | VERIFY_EMAILS: 'true' 90 | }, 91 | request, 92 | async () => { 93 | await registerAndLoginAccount(request).then(({ email, password }) => { 94 | const new_email = generateRandomEmail() 95 | 96 | request 97 | .post('/auth/change-email/request') 98 | .send({ new_email }) 99 | .expect(204) 100 | .end(async (err) => { 101 | if (err) return done(err) 102 | const [message] = await mailHogSearch(new_email) 103 | expect(message).toBeTruthy() 104 | expect(message.Content.Headers.Subject).toInclude('Change your email address') 105 | const ticket = message.Content.Headers['X-Ticket'][0] 106 | expect(ticket).toBeString() 107 | await deleteMailHogEmail(message) 108 | 109 | request 110 | .post('/auth/change-email/change') 111 | .send({ ticket }) 112 | .expect(204) 113 | .end(async (err) => { 114 | if (err) return done(err) 115 | 116 | request 117 | .post('/auth/login') 118 | .send({ email: new_email, password }) 119 | .expect(200) 120 | .end(end(done)) 121 | }) 122 | }) 123 | }) 124 | } 125 | ) 126 | }) 127 | -------------------------------------------------------------------------------- /src/routes/auth/change-email/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import requestVerification from './request-verification' 3 | import directChange from './direct-change' 4 | import changeVerified from './verify-and-change' 5 | import { APPLICATION, AUTHENTICATION } from '@shared/config' 6 | 7 | if (AUTHENTICATION.NOTIFY_EMAIL_CHANGE && !APPLICATION.EMAILS_ENABLE) 8 | console.warn( 9 | "NOTIFY_EMAIL_CHANGE has been enabled but SMTP is not enabled. Email change notifications won't be sent." 10 | ) 11 | 12 | const router = Router() 13 | 14 | router.use((req, res, next) => { 15 | if (!AUTHENTICATION.CHANGE_EMAIL_ENABLED) { 16 | return res.boom.badImplementation( 17 | `Please set the CHANGE_EMAIL_ENABLED env variable to true to use the auth/change-email routes.` 18 | ) 19 | } else { 20 | return next() 21 | } 22 | }) 23 | 24 | router.post('/request', requestVerification) 25 | router.post('/change', changeVerified) 26 | router.post('/', directChange) 27 | 28 | export default router 29 | -------------------------------------------------------------------------------- /src/routes/auth/change-email/request-verification.ts: -------------------------------------------------------------------------------- 1 | import { Response } from 'express' 2 | import { v4 as uuidv4 } from 'uuid' 3 | 4 | import { setNewTicket, setNewEmail } from '@shared/queries' 5 | import { asyncWrapper } from '@shared/helpers' 6 | import { APPLICATION, AUTHENTICATION } from '@shared/config' 7 | import { emailClient } from '@shared/email' 8 | import { request } from '@shared/request' 9 | import { SetNewEmailData } from '@shared/types' 10 | 11 | import { getRequestInfo } from './utils' 12 | import { RequestExtended } from '@shared/types' 13 | 14 | async function requestChangeEmail(req: RequestExtended, res: Response): Promise { 15 | if(!AUTHENTICATION.VERIFY_EMAILS) { 16 | return res.boom.badImplementation(`Please set the VERIFY_EMAILS env variable to true to use the auth/change-email/request route.`) 17 | } 18 | 19 | const { user_id, new_email } = await getRequestInfo(req, res) 20 | 21 | // smtp must be enabled for request change password to work. 22 | if (!APPLICATION.EMAILS_ENABLE) { 23 | return res.boom.badImplementation('SMTP settings unavailable') 24 | } 25 | 26 | // generate new ticket and ticket_expires_at 27 | const ticket = uuidv4() 28 | const now = new Date() 29 | const ticket_expires_at = new Date() 30 | // ticket active for 60 minutes 31 | ticket_expires_at.setTime(now.getTime() + 60 * 60 * 1000) 32 | // set new ticket 33 | try { 34 | await request(setNewTicket, { 35 | user_id, 36 | ticket, 37 | ticket_expires_at 38 | }) 39 | } catch (error) { 40 | console.error('Unable to set new ticket for user') 41 | return res.boom.badImplementation('Unable to set new ticket') 42 | } 43 | // set new email 44 | let display_name 45 | try { 46 | const setNewEmailReturn = await request(setNewEmail, { user_id, new_email }) 47 | display_name = setNewEmailReturn.update_auth_accounts.returning[0].user.display_name 48 | } catch (error) { 49 | console.error(error) 50 | return res.boom.badImplementation('unable to set new email') 51 | } 52 | // send email 53 | try { 54 | await emailClient.send({ 55 | template: 'change-email', 56 | locals: { 57 | ticket, 58 | url: APPLICATION.SERVER_URL, 59 | display_name 60 | }, 61 | message: { 62 | to: new_email, 63 | headers: { 64 | 'x-ticket': { 65 | prepared: true, 66 | value: ticket 67 | } 68 | } 69 | } 70 | }) 71 | } catch (err) { 72 | console.error('Unable to send email') 73 | console.error(err) 74 | return res.boom.badImplementation() 75 | } 76 | 77 | return res.status(204).send() 78 | } 79 | 80 | export default asyncWrapper(requestChangeEmail) 81 | -------------------------------------------------------------------------------- /src/routes/auth/change-email/utils.ts: -------------------------------------------------------------------------------- 1 | import { selectAccountByEmail } from '@shared/helpers' 2 | import { emailResetSchema } from '@shared/validation' 3 | import { RequestExtended } from '@shared/types' 4 | import { Response } from 'express' 5 | 6 | export const getRequestInfo = async ( 7 | req: RequestExtended, 8 | res: Response 9 | ): Promise<{ user_id: string | number; new_email: string }> => { 10 | if (!req.permission_variables) { 11 | throw res.boom.unauthorized('Not logged in') 12 | } 13 | 14 | const { 'user-id': user_id } = req.permission_variables 15 | 16 | // validate new email 17 | const { new_email } = await emailResetSchema.validateAsync(req.body) 18 | 19 | // make sure new_email is not attached to an account yet 20 | let account_exists = true 21 | try { 22 | await selectAccountByEmail(new_email) 23 | // Account using new_email already exists - pass 24 | } catch { 25 | // No existing account is using the new email address. Good! 26 | account_exists = false 27 | } 28 | 29 | if (account_exists) { 30 | throw res.boom.badRequest('Cannot use this email.') 31 | } 32 | return { 33 | user_id, 34 | new_email 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/routes/auth/change-email/verify-and-change.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express' 2 | import { asyncWrapper, rotateTicket, selectAccountByTicket } from '@shared/helpers' 3 | import { changeEmailByTicket } from '@shared/queries' 4 | 5 | import { request } from '@shared/request' 6 | import { verifySchema } from '@shared/validation' 7 | import { AccountData, UpdateAccountData } from '@shared/types' 8 | import { v4 as uuidv4 } from 'uuid' 9 | import { APPLICATION, AUTHENTICATION } from '@shared/config' 10 | import { emailClient } from '@shared/email' 11 | 12 | async function changeEmail({ body }: Request, res: Response): Promise { 13 | if(!AUTHENTICATION.VERIFY_EMAILS) { 14 | return res.boom.badImplementation(`Please set the VERIFY_EMAILS env variable to true to use the auth/change-email/change route.`) 15 | } 16 | 17 | const { ticket } = await verifySchema.validateAsync(body) 18 | 19 | let email: AccountData['email'] 20 | let new_email: AccountData['new_email'] 21 | let user: AccountData['user'] 22 | 23 | try { 24 | const account = await selectAccountByTicket(ticket) 25 | email = account.email 26 | new_email = account.new_email 27 | user = account.user 28 | } catch(err) { 29 | return res.boom.badRequest(err.message); 30 | } 31 | 32 | const hasuraData = await request(changeEmailByTicket, { 33 | ticket, 34 | new_email, 35 | now: new Date(), 36 | new_ticket: uuidv4() 37 | }) 38 | 39 | if (!hasuraData.update_auth_accounts.affected_rows) { 40 | return res.boom.unauthorized('Invalid or expired ticket.') 41 | } 42 | 43 | if (AUTHENTICATION.NOTIFY_EMAIL_CHANGE && APPLICATION.EMAILS_ENABLE) { 44 | try { 45 | await emailClient.send({ 46 | template: 'notify-email-change', 47 | locals: { 48 | url: APPLICATION.SERVER_URL, 49 | display_name: user.display_name 50 | }, 51 | message: { 52 | to: email 53 | } 54 | }) 55 | } catch (err) { 56 | console.error('Unable to send email') 57 | console.error(err) 58 | return res.boom.badImplementation() 59 | } 60 | } 61 | await rotateTicket(ticket) 62 | 63 | return res.status(204).send() 64 | } 65 | 66 | export default asyncWrapper(changeEmail) 67 | -------------------------------------------------------------------------------- /src/routes/auth/change-password/change.test.ts: -------------------------------------------------------------------------------- 1 | import 'jest-extended' 2 | 3 | import { request } from '@test/server' 4 | import { request as gqlRequest } from '@shared/request' 5 | import { end, saveJwt } from '@test/supertest-shared-utils' 6 | import { generateRandomString, registerAccount, registerAndLoginAccount } from '@test/utils' 7 | import { updateAccountByEmail } from '@shared/queries' 8 | 9 | it('should change the password from the old password', (done) => { 10 | const new_password = generateRandomString() 11 | 12 | registerAndLoginAccount(request).then(({ password }) => { 13 | request 14 | .post('/auth/change-password') 15 | .send({ old_password: password, new_password }) 16 | .expect(204) 17 | .end(end(done)) 18 | }) 19 | }) 20 | 21 | it('should change the password if current password is null', async () => { 22 | const new_password = generateRandomString() 23 | 24 | const { email } = await registerAndLoginAccount(request) 25 | 26 | // update user's password to null 27 | await gqlRequest(updateAccountByEmail, { 28 | account_email: email, 29 | account: { 30 | password_hash: null 31 | } 32 | }) 33 | 34 | await request 35 | .post('/auth/change-password') 36 | .send({ old_password: 'does not matter', new_password }) 37 | .expect(204) 38 | }) 39 | 40 | it('should fail to change the password if with wrong old password', async () => { 41 | const new_password = generateRandomString() 42 | 43 | const { email } = await registerAndLoginAccount(request) 44 | 45 | // update user's password to null 46 | await gqlRequest(updateAccountByEmail, { 47 | account_email: email, 48 | account: { 49 | password_hash: 'other hash' 50 | } 51 | }) 52 | 53 | await request 54 | .post('/auth/change-password') 55 | .send({ old_password: 'does not matter', new_password }) 56 | .expect(401) 57 | }) 58 | 59 | it('should change password using old password without cookies', (done) => { 60 | const new_password = generateRandomString() 61 | let jwtToken = '' 62 | 63 | registerAccount(request).then(({ email, password }) => { 64 | request 65 | .post('/auth/login') 66 | .send({ email, password, cookie: false }) 67 | .expect(saveJwt((j) => (jwtToken = j))) 68 | .end((err) => { 69 | if (err) return done(err) 70 | 71 | request 72 | .post('/auth/change-password') 73 | .set({ Authorization: `Bearer ${jwtToken}` }) 74 | .send({ old_password: password, new_password }) 75 | .expect(204) 76 | .end(end(done)) 77 | }) 78 | }) 79 | }) 80 | -------------------------------------------------------------------------------- /src/routes/auth/change-password/change.ts: -------------------------------------------------------------------------------- 1 | import { Response } from 'express' 2 | import bcrypt from 'bcryptjs' 3 | 4 | import { asyncWrapper, checkHibp, hashPassword, selectAccountByUserId } from '@shared/helpers' 5 | import { changePasswordFromOldSchema } from '@shared/validation' 6 | import { updatePasswordWithUserId } from '@shared/queries' 7 | import { request } from '@shared/request' 8 | import { AccountData, RequestExtended } from '@shared/types' 9 | 10 | /** 11 | * Change the password from the current one 12 | */ 13 | async function basicPasswordChange(req: RequestExtended, res: Response): Promise { 14 | if (!req.permission_variables) { 15 | return res.boom.unauthorized('Not logged in') 16 | } 17 | 18 | const { 'user-id': user_id } = req.permission_variables 19 | 20 | const { old_password, new_password } = await changePasswordFromOldSchema.validateAsync(req.body) 21 | 22 | try { 23 | await checkHibp(new_password) 24 | } catch (err) { 25 | return res.boom.badRequest(err.message) 26 | } 27 | 28 | // Search the account from the JWT's account id 29 | let password_hash: AccountData['password_hash'] 30 | try { 31 | const account = await selectAccountByUserId(user_id) 32 | password_hash = account.password_hash 33 | } catch (err) { 34 | return res.boom.badRequest(err.message) 35 | } 36 | 37 | // Check the old (current) password 38 | // but only if there is a previous password set 39 | if (password_hash && !(await bcrypt.compare(old_password, password_hash))) { 40 | return res.boom.unauthorized('Incorrect current password.') 41 | } 42 | 43 | let newPasswordHash: string 44 | try { 45 | newPasswordHash = await hashPassword(new_password) 46 | } catch (err) { 47 | return res.boom.internal(err.message) 48 | } 49 | 50 | await request(updatePasswordWithUserId, { 51 | user_id, 52 | password_hash: newPasswordHash 53 | }) 54 | 55 | return res.status(204).send() 56 | } 57 | 58 | export default asyncWrapper(basicPasswordChange) 59 | -------------------------------------------------------------------------------- /src/routes/auth/change-password/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | 3 | import lost from './lost' 4 | import change from './change' 5 | import reset from './reset' 6 | 7 | const router = Router() 8 | 9 | router.post('/', change) 10 | 11 | router.post('/request', lost) 12 | router.post('/change', reset) 13 | 14 | export default router 15 | -------------------------------------------------------------------------------- /src/routes/auth/change-password/lost.test.ts: -------------------------------------------------------------------------------- 1 | import 'jest-extended' 2 | 3 | import { request } from '@test/server' 4 | import { 5 | mailHogSearch, 6 | deleteMailHogEmail, 7 | withEnv, 8 | registerAccount, 9 | generateRandomString 10 | } from '@test/utils' 11 | import { end } from '@test/supertest-shared-utils' 12 | 13 | describe('Reset lost password', () => { 14 | beforeAll(async () => { 15 | withEnv( 16 | { 17 | LOST_PASSWORD_ENABLED: 'true' 18 | }, 19 | request 20 | ) 21 | }) 22 | 23 | let ticket: string 24 | 25 | it('should request a reset ticket to be sent by email', (done) => { 26 | registerAccount(request).then(({ email }) => { 27 | request 28 | .post('/auth/change-password/request') 29 | .send({ email: email }) 30 | .expect(204) 31 | .end(end(done)) 32 | }) 33 | }) 34 | 35 | it('should receive a ticket by email', (done) => { 36 | registerAccount(request).then(({ email }) => { 37 | request 38 | .post('/auth/change-password/request') 39 | .send({ email: email }) 40 | .expect(204) 41 | .end(async (err) => { 42 | if (err) return done(err) 43 | 44 | const [message] = await mailHogSearch(email) 45 | expect(message).toBeTruthy() 46 | expect(message.Content.Headers.Subject).toInclude('Reset your password') 47 | ticket = message.Content.Headers['X-Ticket'][0] 48 | expect(ticket).toBeString() 49 | await deleteMailHogEmail(message) 50 | 51 | done() 52 | }) 53 | }) 54 | }) 55 | 56 | it('should change the password from a ticket', (done) => { 57 | registerAccount(request).then(({ email }) => { 58 | request 59 | .post('/auth/change-password/request') 60 | .send({ email: email }) 61 | .expect(204) 62 | .end(async (err) => { 63 | if (err) return done(err) 64 | 65 | const [message] = await mailHogSearch(email) 66 | expect(message).toBeTruthy() 67 | expect(message.Content.Headers.Subject).toInclude('Reset your password') 68 | ticket = message.Content.Headers['X-Ticket'][0] 69 | expect(ticket).toBeString() 70 | await deleteMailHogEmail(message) 71 | 72 | request 73 | .post('/auth/change-password/change') 74 | .send({ 75 | ticket, 76 | new_password: generateRandomString() 77 | }) 78 | .expect(204) 79 | .end(end(done)) 80 | }) 81 | }) 82 | // ? check if the hash has been changed in the DB? 83 | }) 84 | }) 85 | -------------------------------------------------------------------------------- /src/routes/auth/change-password/lost.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express' 2 | import { v4 as uuidv4 } from 'uuid' 3 | 4 | import { asyncWrapper, selectAccountByEmail } from '@shared/helpers' 5 | import { APPLICATION, AUTHENTICATION } from '@shared/config' 6 | import { emailClient } from '@shared/email' 7 | import { forgotSchema } from '@shared/validation' 8 | import { setNewTicket } from '@shared/queries' 9 | import { request } from '@shared/request' 10 | import { AccountData } from '@shared/types' 11 | 12 | /** 13 | * * Creates a new temporary ticket in the account, and optionnaly send the link by email 14 | * Always return status code 204 in order to not leak information about emails in the database 15 | */ 16 | async function requestChangePassword({ body }: Request, res: Response): Promise { 17 | console.log('inside /change-password/request') 18 | 19 | if (!AUTHENTICATION.LOST_PASSWORD_ENABLED) { 20 | return res.boom.badImplementation( 21 | `Please set the LOST_PASSWORD_ENABLED env variable to true to use the auth/change-password/request route.` 22 | ) 23 | } 24 | 25 | // smtp must be enabled for request change password to work. 26 | if (!APPLICATION.EMAILS_ENABLE) { 27 | console.log('emails not enabled') 28 | return res.boom.badImplementation('SMTP settings unavailable') 29 | } 30 | 31 | const { email } = await forgotSchema.validateAsync(body) 32 | 33 | let account: AccountData 34 | 35 | try { 36 | account = await selectAccountByEmail(email) 37 | } catch (err) { 38 | return res.boom.badRequest(err.message) 39 | } 40 | 41 | if (!account) { 42 | console.error('Account does not exist') 43 | return res.status(204).send() 44 | } 45 | 46 | if (!account.active) { 47 | console.error('Account is not active') 48 | return res.status(204).send() 49 | } 50 | 51 | // generate new ticket and ticket_expires_at 52 | const ticket = uuidv4() 53 | const now = new Date() 54 | const ticket_expires_at = new Date() 55 | 56 | // ticket active for 60 minutes 57 | ticket_expires_at.setTime(now.getTime() + 60 * 60 * 1000) 58 | 59 | // set new ticket 60 | try { 61 | await request(setNewTicket, { 62 | user_id: account.user.id, 63 | ticket, 64 | ticket_expires_at 65 | }) 66 | } catch (error) { 67 | console.error('Unable to set new ticket for user') 68 | return res.status(204).send() 69 | } 70 | 71 | // send email 72 | try { 73 | await emailClient.send({ 74 | template: 'lost-password', 75 | locals: { 76 | ticket, 77 | url: APPLICATION.SERVER_URL, 78 | display_name: account.user.display_name 79 | }, 80 | message: { 81 | to: email, 82 | headers: { 83 | 'x-ticket': { 84 | prepared: true, 85 | value: ticket as string 86 | } 87 | } 88 | } 89 | }) 90 | } catch (err) { 91 | console.error('Unable to send email') 92 | console.error(err) 93 | return res.status(204).send() 94 | } 95 | 96 | return res.status(204).send() 97 | } 98 | 99 | export default asyncWrapper(requestChangePassword) 100 | -------------------------------------------------------------------------------- /src/routes/auth/change-password/reset.ts: -------------------------------------------------------------------------------- 1 | import { Response } from 'express' 2 | import { v4 as uuidv4 } from 'uuid' 3 | 4 | import { asyncWrapper, checkHibp, hashPassword } from '@shared/helpers' 5 | import { resetPasswordWithTicketSchema } from '@shared/validation' 6 | import { updatePasswordWithTicket } from '@shared/queries' 7 | import { request } from '@shared/request' 8 | import { UpdateAccountData, RequestExtended } from '@shared/types' 9 | import { AUTHENTICATION } from '@shared/config' 10 | 11 | /** 12 | * Reset the password, either from a valid ticket or from a valid JWT and a valid password 13 | */ 14 | async function resetPassword(req: RequestExtended, res: Response): Promise { 15 | if (!AUTHENTICATION.LOST_PASSWORD_ENABLED) { 16 | return res.boom.badImplementation( 17 | `Please set the LOST_PASSWORD_ENABLED env variable to true to use the auth/change-password/change route.` 18 | ) 19 | } 20 | 21 | // Reset the password from { ticket, new_password } 22 | const { ticket, new_password } = await resetPasswordWithTicketSchema.validateAsync(req.body) 23 | 24 | try { 25 | await checkHibp(new_password) 26 | } catch (err) { 27 | return res.boom.badRequest(err.message) 28 | } 29 | 30 | let password_hash: string 31 | try { 32 | password_hash = await hashPassword(new_password) 33 | } catch (err) { 34 | return res.boom.internal(err.message) 35 | } 36 | 37 | const hasuraData = await request(updatePasswordWithTicket, { 38 | ticket, 39 | password_hash, 40 | now: new Date(), 41 | new_ticket: uuidv4() 42 | }) 43 | 44 | const { affected_rows } = hasuraData.update_auth_accounts 45 | if (!affected_rows) { 46 | return res.boom.unauthorized('Invalid or expired ticket.') 47 | } 48 | 49 | return res.status(204).send() 50 | } 51 | 52 | export default asyncWrapper(resetPassword) 53 | -------------------------------------------------------------------------------- /src/routes/auth/delete.ts: -------------------------------------------------------------------------------- 1 | import { Response } from 'express' 2 | 3 | import { asyncWrapper } from '@shared/helpers' 4 | import { deleteAccountByUserId } from '@shared/queries' 5 | import { request } from '@shared/request' 6 | import { DeleteAccountData, RequestExtended } from '@shared/types' 7 | import { AUTHENTICATION } from '@shared/config' 8 | 9 | async function deleteUser(req: RequestExtended, res: Response): Promise { 10 | if(!AUTHENTICATION.ALLOW_USER_SELF_DELETE) { 11 | return res.boom.badImplementation(`Please set the ALLOW_USER_SELF_DELETE env variable to true to use the auth/delete route.`) 12 | } 13 | 14 | if (!req.permission_variables) { 15 | return res.boom.unauthorized('Unable to delete account') 16 | } 17 | 18 | const { 'user-id': user_id } = req.permission_variables 19 | 20 | const hasuraData = await request(deleteAccountByUserId, { user_id }) 21 | 22 | if (!hasuraData.delete_auth_accounts.affected_rows) { 23 | return res.boom.unauthorized('Unable to delete account') 24 | } 25 | 26 | // clear cookies 27 | res.clearCookie('refresh_token') 28 | res.clearCookie('permission_variables') 29 | return res.status(204).send() 30 | } 31 | 32 | export default asyncWrapper(deleteUser) 33 | -------------------------------------------------------------------------------- /src/routes/auth/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import nocache from 'nocache' 3 | import changeEmail from './change-email' 4 | import getJwks from './jwks' 5 | import loginAccount from './login' 6 | import logout from './logout' 7 | import mfa from './mfa' 8 | import changePassword from './change-password' 9 | import providers from './providers' 10 | import registerAccount from './register' 11 | import token from './token' 12 | import activateAccount from './activate' 13 | import deleteAccount from './delete' 14 | import magicLink from './magic-link' 15 | import { AUTHENTICATION } from '@shared/config' 16 | 17 | const router = Router() 18 | 19 | router.use(nocache()) 20 | 21 | router.use((req, res, next) => { 22 | if (!AUTHENTICATION.ENABLED) { 23 | console.log(`Please set the AUTH_ENABLED env variable to true to use the auth routes.`) 24 | return res.boom.notFound() 25 | } else { 26 | return next() 27 | } 28 | }) 29 | 30 | router.use('/providers', providers) 31 | router.use('/mfa', mfa) 32 | router.use('/change-email', changeEmail) 33 | router.get('/activate', activateAccount) 34 | router.post('/delete', deleteAccount) 35 | router 36 | .post('/login', loginAccount) 37 | .post('/logout', logout) 38 | .post('/register', registerAccount) 39 | .use('/change-password', changePassword) 40 | router.get('/jwks', getJwks) 41 | router.use('/token', token) 42 | router.get('/magic-link', magicLink) 43 | 44 | export default router 45 | -------------------------------------------------------------------------------- /src/routes/auth/jwks.ts: -------------------------------------------------------------------------------- 1 | import { Response } from 'express' 2 | 3 | import { asyncWrapper } from '@shared/helpers' 4 | import { getJwkStore } from '@shared/jwt' 5 | import { RequestExtended } from '@shared/types' 6 | import { JSONWebKeySet } from 'jose' 7 | 8 | const getJwks = async (_req: RequestExtended, res: Response) => { 9 | let jwks: JSONWebKeySet; 10 | try { 11 | jwks = getJwkStore().toJWKS(false) 12 | } catch (err) { 13 | return res.boom.notImplemented(err.message) 14 | } 15 | 16 | return res.send(jwks) 17 | } 18 | 19 | export default asyncWrapper(getJwks) 20 | -------------------------------------------------------------------------------- /src/routes/auth/login.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express' 2 | import bcrypt from 'bcryptjs' 3 | import { v4 as uuidv4 } from 'uuid' 4 | import { asyncWrapper, selectAccount } from '@shared/helpers' 5 | import { newJwtExpiry, createHasuraJwt } from '@shared/jwt' 6 | import { setRefreshToken } from '@shared/cookies' 7 | import { loginAnonymouslySchema, loginSchema, loginSchemaMagicLink } from '@shared/validation' 8 | import { insertAccount, setNewTicket } from '@shared/queries' 9 | import { request } from '@shared/request' 10 | import { AccountData, UserData, Session } from '@shared/types' 11 | import { emailClient } from '@shared/email' 12 | import { AUTHENTICATION, APPLICATION, REGISTRATION, HEADERS } from '@shared/config' 13 | 14 | interface HasuraData { 15 | insert_auth_accounts: { 16 | affected_rows: number 17 | returning: AccountData[] 18 | } 19 | } 20 | 21 | async function loginAccount({ body, headers }: Request, res: Response): Promise { 22 | // default to true 23 | const useCookie = typeof body.cookie !== 'undefined' ? body.cookie : true 24 | 25 | if (AUTHENTICATION.ANONYMOUS_USERS_ENABLED) { 26 | const { anonymous } = await loginAnonymouslySchema.validateAsync(body) 27 | 28 | // if user tries to sign in anonymously 29 | if (anonymous) { 30 | let hasura_data: HasuraData 31 | try { 32 | const ticket = uuidv4() 33 | hasura_data = await request(insertAccount, { 34 | account: { 35 | email: null, 36 | password_hash: null, 37 | ticket, 38 | active: true, 39 | is_anonymous: true, 40 | default_role: REGISTRATION.DEFAULT_ANONYMOUS_ROLE, 41 | account_roles: { 42 | data: [{ role: REGISTRATION.DEFAULT_ANONYMOUS_ROLE }] 43 | }, 44 | user: { 45 | data: { display_name: 'Anonymous user' } 46 | } 47 | } 48 | }) 49 | } catch (error) { 50 | return res.boom.badImplementation('Unable to create user and sign in user anonymously') 51 | } 52 | 53 | if (!hasura_data.insert_auth_accounts.returning.length) { 54 | return res.boom.badImplementation('Unable to create user and sign in user anonymously') 55 | } 56 | 57 | const account = hasura_data.insert_auth_accounts.returning[0] 58 | 59 | const refresh_token = await setRefreshToken(res, account.id, useCookie) 60 | 61 | const jwt_token = createHasuraJwt(account) 62 | const jwt_expires_in = newJwtExpiry 63 | 64 | const session: Session = { jwt_token, jwt_expires_in, user: account.user } 65 | if (useCookie) session.refresh_token = refresh_token 66 | 67 | return res.send(session) 68 | } 69 | } 70 | 71 | // else, login users normally 72 | const { password } = await (AUTHENTICATION.MAGIC_LINK_ENABLED 73 | ? loginSchemaMagicLink 74 | : loginSchema 75 | ).validateAsync(body) 76 | 77 | const account = await selectAccount(body) 78 | 79 | if (!account) { 80 | return res.boom.badRequest('Account does not exist.') 81 | } 82 | 83 | const { id, mfa_enabled, password_hash, active, email } = account 84 | 85 | if (typeof password === 'undefined') { 86 | const refresh_token = await setRefreshToken(res, id, useCookie) 87 | 88 | try { 89 | await emailClient.send({ 90 | template: 'magic-link', 91 | message: { 92 | to: email, 93 | headers: { 94 | 'x-token': { 95 | prepared: true, 96 | value: refresh_token 97 | } 98 | } 99 | }, 100 | locals: { 101 | display_name: account.user.display_name, 102 | token: refresh_token, 103 | url: APPLICATION.SERVER_URL, 104 | action: 'log in', 105 | action_url: 'log-in' 106 | } 107 | }) 108 | 109 | return res.send({ magicLink: true }) 110 | } catch (err) { 111 | console.error(err) 112 | return res.boom.badImplementation() 113 | } 114 | } 115 | 116 | if (!active) { 117 | return res.boom.badRequest('Account is not activated.') 118 | } 119 | 120 | // Handle User Impersonation Check 121 | const adminSecret = headers[HEADERS.ADMIN_SECRET_HEADER] 122 | const hasAdminSecret = Boolean(adminSecret) 123 | const isAdminSecretCorrect = adminSecret === APPLICATION.HASURA_GRAPHQL_ADMIN_SECRET 124 | let userImpersonationValid = false 125 | if (AUTHENTICATION.USER_IMPERSONATION_ENABLED && hasAdminSecret && !isAdminSecretCorrect) { 126 | return res.boom.unauthorized('Invalid x-admin-secret') 127 | } else if (AUTHENTICATION.USER_IMPERSONATION_ENABLED && hasAdminSecret && isAdminSecretCorrect) { 128 | userImpersonationValid = true 129 | } 130 | 131 | // Validate Password 132 | const isPasswordCorrect = await bcrypt.compare(password, password_hash) 133 | if (!isPasswordCorrect && !userImpersonationValid) { 134 | return res.boom.unauthorized('Username and password do not match') 135 | } 136 | 137 | if (mfa_enabled) { 138 | const ticket = uuidv4() 139 | const ticket_expires_at = new Date(+new Date() + 60 * 60 * 1000) 140 | 141 | // set new ticket 142 | await request(setNewTicket, { 143 | user_id: account.user.id, 144 | ticket, 145 | ticket_expires_at 146 | }) 147 | 148 | return res.send({ mfa: true, ticket }) 149 | } 150 | 151 | // refresh_token 152 | const refresh_token = await setRefreshToken(res, id, useCookie) 153 | 154 | // generate JWT 155 | const jwt_token = createHasuraJwt(account) 156 | const jwt_expires_in = newJwtExpiry 157 | const user: UserData = { 158 | id: account.user.id, 159 | display_name: account.user.display_name, 160 | email: account.email, 161 | avatar_url: account.user.avatar_url 162 | } 163 | const session: Session = { jwt_token, jwt_expires_in, user } 164 | if (!useCookie) session.refresh_token = refresh_token 165 | 166 | res.send(session) 167 | } 168 | 169 | export default asyncWrapper(loginAccount) 170 | -------------------------------------------------------------------------------- /src/routes/auth/logout.ts: -------------------------------------------------------------------------------- 1 | import { Response } from 'express' 2 | import { asyncWrapper } from '@shared/helpers' 3 | import { request } from '@shared/request' 4 | import { 5 | selectRefreshToken, 6 | deleteAllAccountRefreshTokens, 7 | deleteRefreshToken 8 | } from '@shared/queries' 9 | import { logoutSchema } from '@shared/validation' 10 | import { AccountData, RequestExtended } from '@shared/types' 11 | 12 | interface HasuraData { 13 | auth_refresh_tokens: { account: AccountData }[] 14 | } 15 | 16 | async function logout({ body, refresh_token }: RequestExtended, res: Response): Promise { 17 | if (!refresh_token || !refresh_token.value) { 18 | return res.boom.unauthorized('Invalid or expired refresh token.') 19 | } 20 | 21 | // clear cookies 22 | if (refresh_token.type === 'cookie') { 23 | res.clearCookie('refresh_token') 24 | res.clearCookie('permission_variables') 25 | } 26 | 27 | // should we delete all refresh tokens to this user or not 28 | const { all } = await logoutSchema.validateAsync(body) 29 | 30 | if (all) { 31 | // get user based on refresh token 32 | let hasura_data: HasuraData | null = null 33 | try { 34 | hasura_data = await request(selectRefreshToken, { 35 | refresh_token: refresh_token.value, 36 | current_timestamp: new Date() 37 | }) 38 | } catch (error) { 39 | return res.status(204).send() 40 | } 41 | 42 | const account = hasura_data?.auth_refresh_tokens?.[0]?.account 43 | 44 | if (!account) { 45 | return res.status(204).send() 46 | } 47 | 48 | // delete all refresh tokens for user 49 | try { 50 | await request(deleteAllAccountRefreshTokens, { 51 | user_id: account.user.id 52 | }) 53 | } catch (error) { 54 | return res.status(204).send() 55 | } 56 | } else { 57 | // if only to delete single refresh token 58 | try { 59 | await request(deleteRefreshToken, { 60 | refresh_token: refresh_token.value 61 | }) 62 | } catch (error) { 63 | return res.status(204).send() 64 | } 65 | } 66 | 67 | return res.status(204).send() 68 | } 69 | 70 | export default asyncWrapper(logout) 71 | -------------------------------------------------------------------------------- /src/routes/auth/magic-link.ts: -------------------------------------------------------------------------------- 1 | import { APPLICATION } from '@shared/config' 2 | import { Request, Response } from 'express' 3 | import Boom from '@hapi/boom' 4 | import { accountOfRefreshToken, activateAccount } from '@shared/queries' 5 | import { asyncWrapper, getEndURLOperator } from '@shared/helpers' 6 | import { request } from '@shared/request' 7 | import { v4 as uuidv4 } from 'uuid' 8 | import { magicLinkQuery } from '@shared/validation' 9 | import { AccountData, UpdateAccountData } from '@shared/types' 10 | import { setRefreshToken } from '@shared/cookies' 11 | 12 | async function magicLink({ query }: Request, res: Response): Promise { 13 | const { token, action } = await magicLinkQuery.validateAsync(query) 14 | const useCookie = typeof query.cookie !== 'undefined' ? query.cookie === 'true' : true 15 | let refresh_token = token 16 | if (action === 'register') { 17 | const new_ticket = uuidv4() 18 | let hasuraData: UpdateAccountData 19 | try { 20 | hasuraData = await request(activateAccount, { 21 | ticket: token, 22 | new_ticket, 23 | now: new Date() 24 | }) 25 | } catch (err) /* istanbul ignore next */ { 26 | console.error(err) 27 | if (APPLICATION.REDIRECT_URL_ERROR) { 28 | return res.redirect(302, APPLICATION.REDIRECT_URL_ERROR) 29 | } 30 | throw err 31 | } 32 | const { affected_rows, returning } = hasuraData.update_auth_accounts 33 | if (!affected_rows) { 34 | console.error('Invalid or expired ticket') 35 | if (APPLICATION.REDIRECT_URL_ERROR) { 36 | return res.redirect(302, APPLICATION.REDIRECT_URL_ERROR) 37 | } 38 | /* istanbul ignore next */ 39 | throw Boom.unauthorized('Invalid or expired token.') 40 | } 41 | refresh_token = await setRefreshToken(res, returning[0].id, useCookie) 42 | } 43 | const hasura_data = await request<{ 44 | auth_refresh_tokens: { account: AccountData }[] 45 | }>(accountOfRefreshToken, { 46 | refresh_token 47 | }) 48 | const account = hasura_data.auth_refresh_tokens?.[0].account 49 | if (!account) { 50 | throw Boom.unauthorized('Invalid or expired token.') 51 | } 52 | 53 | const url_operator = getEndURLOperator({ 54 | url: APPLICATION.REDIRECT_URL_SUCCESS 55 | }) 56 | 57 | // Redirect user with refresh token. 58 | // This is both for when users log in and register. 59 | return res.redirect( 60 | `${APPLICATION.REDIRECT_URL_SUCCESS}${url_operator}refresh_token=${refresh_token}` 61 | ) 62 | } 63 | 64 | export default asyncWrapper(magicLink) 65 | -------------------------------------------------------------------------------- /src/routes/auth/mfa/disable.ts: -------------------------------------------------------------------------------- 1 | import { asyncWrapper, selectAccountByUserId } from '@shared/helpers' 2 | import { Response } from 'express' 3 | import { deleteOtpSecret } from '@shared/queries' 4 | 5 | import { authenticator } from 'otplib' 6 | import { mfaSchema } from '@shared/validation' 7 | import { request } from '@shared/request' 8 | import { AccountData, RequestExtended } from '@shared/types' 9 | 10 | async function disableMfa(req: RequestExtended, res: Response): Promise { 11 | if (!req.permission_variables) { 12 | return res.boom.unauthorized('Not logged in') 13 | } 14 | 15 | const { 'user-id': user_id } = req.permission_variables 16 | 17 | const { code } = await mfaSchema.validateAsync(req.body) 18 | 19 | let otp_secret: AccountData['otp_secret'] 20 | let mfa_enabled: AccountData['mfa_enabled'] 21 | try { 22 | const account = await selectAccountByUserId(user_id) 23 | otp_secret = account.otp_secret 24 | mfa_enabled = account.mfa_enabled 25 | } catch (err) { 26 | return res.boom.badRequest(err.message) 27 | } 28 | 29 | if (!mfa_enabled) { 30 | return res.boom.badRequest('MFA is already disabled.') 31 | } 32 | 33 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 34 | if (!authenticator.check(code, otp_secret!)) { 35 | return res.boom.unauthorized('Invalid two-factor code.') 36 | } 37 | 38 | await request(deleteOtpSecret, { user_id }) 39 | 40 | return res.status(204).send() 41 | } 42 | 43 | export default asyncWrapper(disableMfa) 44 | -------------------------------------------------------------------------------- /src/routes/auth/mfa/enable.ts: -------------------------------------------------------------------------------- 1 | import { asyncWrapper, selectAccountByUserId } from '@shared/helpers' 2 | import { Response } from 'express' 3 | import { updateOtpStatus } from '@shared/queries' 4 | 5 | import { authenticator } from 'otplib' 6 | import { mfaSchema } from '@shared/validation' 7 | import { request } from '@shared/request' 8 | import { AccountData, RequestExtended } from '@shared/types' 9 | 10 | async function enableMfa(req: RequestExtended, res: Response): Promise { 11 | if (!req.permission_variables) { 12 | return res.boom.unauthorized('Not logged in') 13 | } 14 | 15 | const { 'user-id': user_id } = req.permission_variables 16 | const { code } = await mfaSchema.validateAsync(req.body) 17 | 18 | let otp_secret: AccountData['otp_secret'] 19 | let mfa_enabled: AccountData['mfa_enabled'] 20 | try { 21 | const account = await selectAccountByUserId(user_id) 22 | otp_secret = account.otp_secret 23 | mfa_enabled = account.mfa_enabled 24 | } catch (err) { 25 | return res.boom.badRequest(err.message) 26 | } 27 | 28 | if (mfa_enabled) { 29 | return res.boom.badRequest('MFA is already enabled.') 30 | } 31 | 32 | if (!otp_secret) { 33 | return res.boom.badRequest('OTP secret is not set.') 34 | } 35 | 36 | if (!authenticator.check(code, otp_secret)) { 37 | return res.boom.unauthorized('Invalid two-factor code.') 38 | } 39 | 40 | await request(updateOtpStatus, { user_id, mfa_enabled: true }) 41 | 42 | return res.status(204).send() 43 | } 44 | 45 | export default asyncWrapper(enableMfa) 46 | -------------------------------------------------------------------------------- /src/routes/auth/mfa/generate.ts: -------------------------------------------------------------------------------- 1 | import { Response } from 'express' 2 | import { authenticator } from 'otplib' 3 | import { asyncWrapper, createQR } from '@shared/helpers' 4 | import { MFA } from '@shared/config' 5 | import { request } from '@shared/request' 6 | import { updateOtpSecret } from '@shared/queries' 7 | import { RequestExtended } from '@shared/types' 8 | 9 | async function generateMfa(req: RequestExtended, res: Response): Promise { 10 | if (!req.permission_variables) { 11 | return res.boom.unauthorized('Not logged in') 12 | } 13 | 14 | const { 'user-id': user_id } = req.permission_variables 15 | 16 | /** 17 | * Generate OTP secret and key URI. 18 | */ 19 | const otp_secret = authenticator.generateSecret() 20 | const otpAuth = authenticator.keyuri(user_id, MFA.OTP_ISSUER, otp_secret) 21 | 22 | await request(updateOtpSecret, { user_id, otp_secret }) 23 | 24 | let image_url: string 25 | try { 26 | image_url = await createQR(otpAuth) 27 | } catch(err) { 28 | return res.boom.internal(err.message) 29 | } 30 | 31 | return res.send({ image_url, otp_secret }) 32 | } 33 | 34 | export default asyncWrapper(generateMfa) 35 | -------------------------------------------------------------------------------- /src/routes/auth/mfa/index.ts: -------------------------------------------------------------------------------- 1 | import { MFA } from '@shared/config' 2 | import { Router } from 'express' 3 | import disableMfa from './disable' 4 | import enableMfa from './enable' 5 | import generateMfa from './generate' 6 | import totpLogin from './totp' 7 | 8 | const router = Router() 9 | 10 | router.use((req, res, next) => { 11 | if (!MFA.ENABLED) { 12 | return res.boom.badImplementation( 13 | `Please set the MFA_ENABLED env variable to true to use the auth/mfa routes.` 14 | ) 15 | } else { 16 | return next() 17 | } 18 | }) 19 | 20 | export default router 21 | .post('/disable', disableMfa) 22 | .post('/enable', enableMfa) 23 | .post('/generate', generateMfa) 24 | .post('/totp', totpLogin) 25 | -------------------------------------------------------------------------------- /src/routes/auth/mfa/totp.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express' 2 | import { asyncWrapper, rotateTicket, selectAccount } from '@shared/helpers' 3 | import { newJwtExpiry, createHasuraJwt } from '@shared/jwt' 4 | import { setRefreshToken } from '@shared/cookies' 5 | import { UserData, Session } from '@shared/types' 6 | 7 | import { authenticator } from 'otplib' 8 | import { totpSchema } from '@shared/validation' 9 | 10 | // Increase the authenticator window so that TOTP codes from the previous 30 seconds are also valid 11 | authenticator.options = { 12 | window: [1, 0] 13 | } 14 | 15 | async function totpLogin({ body }: Request, res: Response): Promise { 16 | const { ticket, code } = await totpSchema.validateAsync(body) 17 | const account = await selectAccount(body) 18 | 19 | // default to true 20 | const useCookie = typeof body.cookie !== 'undefined' ? body.cookie : true 21 | 22 | if (!account) { 23 | return res.boom.unauthorized('Invalid or expired ticket.') 24 | } 25 | 26 | const { id, otp_secret, mfa_enabled, active } = account 27 | 28 | if (!mfa_enabled) { 29 | return res.boom.badRequest('MFA is not enabled.') 30 | } 31 | 32 | if (!active) { 33 | return res.boom.badRequest('Account is not activated.') 34 | } 35 | 36 | if (!otp_secret) { 37 | return res.boom.badRequest('OTP secret is not set.') 38 | } 39 | 40 | if (!authenticator.check(code, otp_secret)) { 41 | return res.boom.unauthorized('Invalid two-factor code.') 42 | } 43 | 44 | const refresh_token = await setRefreshToken(res, id, useCookie) 45 | await rotateTicket(ticket) 46 | const jwt_token = createHasuraJwt(account) 47 | const jwt_expires_in = newJwtExpiry 48 | const user: UserData = { 49 | id: account.user.id, 50 | display_name: account.user.display_name, 51 | email: account.email, 52 | avatar_url: account.user.avatar_url 53 | } 54 | 55 | const session: Session = { jwt_token, jwt_expires_in, user } 56 | 57 | if (!useCookie) session.refresh_token = refresh_token 58 | res.send(session) 59 | } 60 | 61 | export default asyncWrapper(totpLogin) 62 | -------------------------------------------------------------------------------- /src/routes/auth/providers/apple.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import { Strategy, Profile } from '@nicokaiser/passport-apple' 3 | import { PROVIDERS } from '@shared/config' 4 | import { initProvider } from './utils' 5 | import { UserData } from '@shared/types' 6 | 7 | const transformProfile = ({ id, name, email, photos }: Profile): UserData => ({ 8 | id, 9 | email, 10 | display_name: name ? `${name.firstName} ${name.lastName}` : email, 11 | avatar_url: photos?.[0].value 12 | }) 13 | 14 | export default (router: Router): void => { 15 | const options = PROVIDERS.apple 16 | 17 | initProvider( 18 | router, 19 | 'apple', 20 | Strategy, 21 | { 22 | scope: ['name', 'email'], 23 | transformProfile, 24 | callbackMethod: 'POST' 25 | }, 26 | (req, res, next) => { 27 | if (!PROVIDERS.apple) { 28 | return res.boom.badImplementation( 29 | `Please set the APPLE_ENABLED env variable to true to use the auth/providers/apple routes.` 30 | ) 31 | } else if (!options?.clientID || !options?.teamID || !options?.keyID || !options?.key) { 32 | return res.boom.badImplementation(`Missing environment variables for Apple OAuth.`) 33 | } else { 34 | return next() 35 | } 36 | } 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /src/routes/auth/providers/facebook.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import { Strategy } from 'passport-facebook' 3 | import { PROVIDERS } from '@shared/config' 4 | import { initProvider } from './utils' 5 | 6 | export default (router: Router): void => { 7 | const options = PROVIDERS.facebook 8 | 9 | initProvider( 10 | router, 11 | 'facebook', 12 | Strategy, 13 | { profileFields: ['email', 'photos', 'displayName'] }, 14 | (req, res, next) => { 15 | if (!PROVIDERS.facebook) { 16 | return res.boom.badImplementation( 17 | `Please set the FACEBOOK_ENABLED env variable to true to use the auth/providers/facebook routes.` 18 | ) 19 | } else if (!options?.clientID || !options?.clientSecret) { 20 | return res.boom.badImplementation(`Missing environment variables for Facebook OAuth.`) 21 | } else { 22 | return next() 23 | } 24 | } 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/routes/auth/providers/github.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import { Strategy } from 'passport-github2' 3 | import { PROVIDERS } from '@shared/config' 4 | import { initProvider } from './utils' 5 | 6 | export default (router: Router): void => { 7 | const options = PROVIDERS.github 8 | 9 | initProvider(router, 'github', Strategy, { scope: ['user:email'] }, (req, res, next) => { 10 | if (!PROVIDERS.github) { 11 | return res.boom.badImplementation( 12 | `Please set the GITHUB_ENABLED env variable to true to use the auth/providers/github routes.` 13 | ) 14 | } else if (!options?.clientID || !options?.clientSecret) { 15 | return res.boom.badImplementation(`Missing environment variables for GitHub OAuth.`) 16 | } else { 17 | return next() 18 | } 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /src/routes/auth/providers/google.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import { Strategy } from 'passport-google-oauth20' 3 | import { initProvider } from './utils' 4 | import { PROVIDERS } from '@shared/config' 5 | 6 | export default (router: Router): void => { 7 | const options = PROVIDERS.google 8 | 9 | initProvider(router, 'google', Strategy, { scope: ['email', 'profile'] }, (req, res, next) => { 10 | if (!PROVIDERS.google) { 11 | return res.boom.badImplementation( 12 | `Please set the GOOGLE_ENABLED env variable to true to use the auth/providers/google routes.` 13 | ) 14 | } else if (!options?.clientID || !options?.clientSecret) { 15 | return res.boom.badImplementation(`Missing environment variables for Google OAuth.`) 16 | } else { 17 | return next() 18 | } 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /src/routes/auth/providers/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | 3 | import github from './github' 4 | import google from './google' 5 | import facebook from './facebook' 6 | import twitter from './twitter' 7 | import apple from './apple' 8 | import windowslive from './windowslive' 9 | import linkedin from './linkedin' 10 | import spotify from './spotify' 11 | 12 | const router = Router() 13 | 14 | github(router) 15 | google(router) 16 | facebook(router) 17 | twitter(router) 18 | apple(router) 19 | windowslive(router) 20 | linkedin(router) 21 | spotify(router) 22 | 23 | export default router 24 | -------------------------------------------------------------------------------- /src/routes/auth/providers/linkedin.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import { Strategy } from 'passport-linkedin-oauth2' 3 | import { initProvider } from './utils' 4 | import { PROVIDERS } from '@shared/config' 5 | 6 | export default (router: Router): void => { 7 | const options = PROVIDERS.linkedin 8 | 9 | initProvider( 10 | router, 11 | 'linkedin', 12 | Strategy, 13 | { 14 | scope: ['r_emailaddress', 'r_liteprofile'] 15 | }, 16 | (req, res, next) => { 17 | if (!PROVIDERS.linkedin) { 18 | return res.boom.badImplementation( 19 | `Please set the LINKEDIN_ENABLED env variable to true to use the auth/providers/linkedin routes.` 20 | ) 21 | } else if (!options?.clientID || !options?.clientSecret) { 22 | return res.boom.badImplementation(`Missing environment variables for LinkedIn OAuth.`) 23 | } else { 24 | return next() 25 | } 26 | } 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /src/routes/auth/providers/spotify.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import { Strategy } from 'passport-spotify' 3 | import { PROVIDERS } from '@shared/config' 4 | import { initProvider } from './utils' 5 | 6 | export default (router: Router): void => { 7 | const options = PROVIDERS.spotify 8 | 9 | initProvider( 10 | router, 11 | 'spotify', 12 | Strategy, 13 | { 14 | scope: ['user-read-email', 'user-read-private'] 15 | }, 16 | (req, res, next) => { 17 | if (!PROVIDERS.spotify) { 18 | return res.boom.badImplementation( 19 | `Please set the SPOTIFY_ENABLED env variable to true to use the auth/providers/spotify routes.` 20 | ) 21 | } else if (!options?.clientID || !options?.clientSecret) { 22 | return res.boom.badImplementation(`Missing environment variables for Spotify OAuth.`) 23 | } else { 24 | return next() 25 | } 26 | } 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /src/routes/auth/providers/twitter.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import { Strategy } from 'passport-twitter' 3 | import { initProvider } from './utils' 4 | import { PROVIDERS, COOKIES } from '@shared/config' 5 | 6 | export default (router: Router): void => { 7 | const options = PROVIDERS.twitter 8 | 9 | initProvider( 10 | router, 11 | 'twitter', 12 | Strategy, 13 | { 14 | userProfileURL: 15 | 'https://api.twitter.com/1.1/account/verify_credentials.json?include_email=true', 16 | includeEmail: true 17 | }, 18 | (req, res, next) => { 19 | if (!PROVIDERS.twitter) { 20 | return res.boom.badImplementation( 21 | `Please set the TWITTER_ENABLED env variable to true to use the auth/providers/twitter routes.` 22 | ) 23 | } else if (!options?.consumerKey || !options?.consumerSecret || !COOKIES.SECRET) { 24 | return res.boom.badImplementation(`Missing environment variables for Twitter OAuth.`) 25 | } else if (!COOKIES.SECRET) { 26 | return res.boom.badImplementation( 27 | 'Missing COOKIE_SECRET environment variable that is required for Twitter OAuth.' 28 | ) 29 | } else { 30 | return next() 31 | } 32 | } 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /src/routes/auth/providers/windowslive.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import { Strategy } from 'passport-windowslive' 3 | import { initProvider } from './utils' 4 | import { PROVIDERS } from '@shared/config' 5 | 6 | export default (router: Router): void => { 7 | const options = PROVIDERS.windowslive 8 | 9 | initProvider( 10 | router, 11 | 'windowslive', 12 | Strategy, 13 | { 14 | scope: [ 15 | 'wl.basic', 16 | 'wl.emails', 17 | // The scope 'wl.contacts_emails' is a undocumented scope which allows us 18 | // to retrieve the email address of the Windows Live account 19 | 'wl.contacts_emails' 20 | ] 21 | }, 22 | (req, res, next) => { 23 | if (!PROVIDERS.windowslive) { 24 | return res.boom.badImplementation( 25 | `Please set the WINDOWSLIVE_ENABLED env variable to true to use the auth/providers/windowslive routes.` 26 | ) 27 | } else if (!options?.clientID || !options?.clientSecret) { 28 | return res.boom.badImplementation(`Missing environment variables for Windows Live OAuth.`) 29 | } else { 30 | return next() 31 | } 32 | } 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /src/routes/auth/register.ts: -------------------------------------------------------------------------------- 1 | import { AUTHENTICATION, APPLICATION, REGISTRATION } from '@shared/config' 2 | import { Request, Response } from 'express' 3 | import { asyncWrapper, checkHibp, hashPassword, selectAccount } from '@shared/helpers' 4 | import { newJwtExpiry, createHasuraJwt } from '@shared/jwt' 5 | 6 | import { emailClient } from '@shared/email' 7 | import { insertAccount } from '@shared/queries' 8 | import { setRefreshToken } from '@shared/cookies' 9 | import { getRegisterSchema, getRegisterSchemaMagicLink } from '@shared/validation' 10 | import { request } from '@shared/request' 11 | import { v4 as uuidv4 } from 'uuid' 12 | import { InsertAccountData, UserData, Session } from '@shared/types' 13 | 14 | async function registerAccount(req: Request, res: Response): Promise { 15 | const body = req.body 16 | 17 | const useCookie = typeof body.cookie !== 'undefined' ? body.cookie : true 18 | 19 | const { 20 | email, 21 | password, 22 | user_data = {}, 23 | register_options = {} 24 | } = await (AUTHENTICATION.MAGIC_LINK_ENABLED 25 | ? getRegisterSchemaMagicLink() 26 | : getRegisterSchema() 27 | ).validateAsync(body) 28 | 29 | const selectedAccount = await selectAccount(body) 30 | if (selectedAccount) { 31 | if (!selectedAccount.active) { 32 | return res.boom.badRequest('Account already exists but is not activated.') 33 | } 34 | return res.boom.badRequest('Account already exists.') 35 | } 36 | 37 | let password_hash: string | null = null 38 | 39 | const ticket = uuidv4() 40 | const ticket_expires_at = new Date(+new Date() + 60 * 60 * 1000).toISOString() // active for 60 minutes 41 | 42 | if (typeof password !== 'undefined') { 43 | try { 44 | await checkHibp(password) 45 | } catch (err) { 46 | return res.boom.badRequest(err.message) 47 | } 48 | 49 | try { 50 | password_hash = await hashPassword(password) 51 | } catch (err) { 52 | return res.boom.internal(err.message) 53 | } 54 | } 55 | 56 | const defaultRole = register_options.default_role ?? REGISTRATION.DEFAULT_USER_ROLE 57 | const allowedRoles = register_options.allowed_roles ?? REGISTRATION.DEFAULT_ALLOWED_USER_ROLES 58 | 59 | // check if default role is part of allowedRoles 60 | if (!allowedRoles.includes(defaultRole)) { 61 | return res.boom.badRequest('Default role must be part of allowed roles.') 62 | } 63 | 64 | // check if allowed roles is a subset of ALLOWED_ROLES 65 | if (!allowedRoles.every((role: string) => REGISTRATION.ALLOWED_USER_ROLES.includes(role))) { 66 | return res.boom.badRequest('allowed roles must be a subset of ALLOWED_ROLES') 67 | } 68 | 69 | const accountRoles = allowedRoles.map((role: string) => ({ role })) 70 | 71 | let accounts: InsertAccountData 72 | try { 73 | accounts = await request(insertAccount, { 74 | account: { 75 | email, 76 | password_hash, 77 | ticket, 78 | ticket_expires_at, 79 | active: REGISTRATION.AUTO_ACTIVATE_NEW_USERS, 80 | default_role: defaultRole, 81 | account_roles: { 82 | data: accountRoles 83 | }, 84 | user: { 85 | data: { display_name: email, ...user_data } 86 | } 87 | } 88 | }) 89 | } catch (e) { 90 | console.error('Error inserting user account') 91 | console.error(e) 92 | return res.boom.badImplementation('Error inserting user account') 93 | } 94 | 95 | const account = accounts.insert_auth_accounts.returning[0] 96 | 97 | const user: UserData = { 98 | id: account.user.id, 99 | display_name: account.user.display_name, 100 | email: account.email, 101 | avatar_url: account.user.avatar_url 102 | } 103 | 104 | if (!REGISTRATION.AUTO_ACTIVATE_NEW_USERS && AUTHENTICATION.VERIFY_EMAILS) { 105 | if (!APPLICATION.EMAILS_ENABLE) { 106 | return res.boom.badImplementation('SMTP settings unavailable') 107 | } 108 | 109 | // use display name from `user_data` if available 110 | const display_name = 'display_name' in user_data ? user_data.display_name : email 111 | 112 | if (typeof password === 'undefined') { 113 | try { 114 | await emailClient.send({ 115 | template: 'magic-link', 116 | message: { 117 | to: user.email, 118 | headers: { 119 | 'x-token': { 120 | prepared: true, 121 | value: ticket 122 | } 123 | } 124 | }, 125 | locals: { 126 | display_name, 127 | token: ticket, 128 | url: APPLICATION.SERVER_URL, 129 | action: 'register', 130 | action_url: 'register' 131 | } 132 | }) 133 | } catch (err) { 134 | console.error(err) 135 | return res.boom.badImplementation() 136 | } 137 | 138 | const session: Session = { jwt_token: null, jwt_expires_in: null, user } 139 | return res.send(session) 140 | } 141 | 142 | try { 143 | await emailClient.send({ 144 | template: 'activate-account', 145 | message: { 146 | to: email, 147 | headers: { 148 | 'x-ticket': { 149 | prepared: true, 150 | value: ticket 151 | } 152 | } 153 | }, 154 | locals: { 155 | display_name, 156 | ticket, 157 | url: APPLICATION.SERVER_URL 158 | } 159 | }) 160 | } catch (err) { 161 | console.error(err) 162 | return res.boom.badImplementation() 163 | } 164 | 165 | const session: Session = { jwt_token: null, jwt_expires_in: null, user } 166 | return res.send(session) 167 | } 168 | 169 | const refresh_token = await setRefreshToken(res, account.id, useCookie) 170 | 171 | // generate JWT 172 | const jwt_token = createHasuraJwt(account) 173 | const jwt_expires_in = newJwtExpiry 174 | 175 | const session: Session = { jwt_token, jwt_expires_in, user } 176 | if (!useCookie) session.refresh_token = refresh_token 177 | 178 | return res.send(session) 179 | } 180 | 181 | export default asyncWrapper(registerAccount) 182 | -------------------------------------------------------------------------------- /src/routes/auth/token/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import refreshToken from './refresh' 3 | import revokeToken from './revoke' 4 | 5 | export default Router().get('/refresh', refreshToken).post('/revoke', revokeToken) 6 | -------------------------------------------------------------------------------- /src/routes/auth/token/refresh.ts: -------------------------------------------------------------------------------- 1 | import { asyncWrapper } from '@shared/helpers' 2 | import { Response } from 'express' 3 | import { selectRefreshToken, updateRefreshToken } from '@shared/queries' 4 | 5 | import { newJwtExpiry, createHasuraJwt, generatePermissionVariables } from '@shared/jwt' 6 | import { newRefreshExpiry, setCookie } from '@shared/cookies' 7 | import { request } from '@shared/request' 8 | import { v4 as uuidv4 } from 'uuid' 9 | import { AccountData, UserData, Session, RequestExtended } from '@shared/types' 10 | 11 | interface HasuraData { 12 | auth_refresh_tokens: { account: AccountData }[] 13 | } 14 | 15 | async function refreshToken({ refresh_token }: RequestExtended, res: Response): Promise { 16 | if (!refresh_token || !refresh_token.value) { 17 | return res.boom.unauthorized('Invalid or expired refresh token.') 18 | } 19 | 20 | // get account based on refresh token 21 | const { auth_refresh_tokens } = await request(selectRefreshToken, { 22 | refresh_token: refresh_token.value, 23 | current_timestamp: new Date() 24 | }) 25 | 26 | if (!auth_refresh_tokens?.length) { 27 | return res.boom.unauthorized('Invalid or expired refresh token.') 28 | } 29 | 30 | // create a new refresh token 31 | const new_refresh_token = uuidv4() 32 | const { account } = auth_refresh_tokens[0] 33 | 34 | // delete old refresh token 35 | // and insert new refresh token 36 | try { 37 | await request(updateRefreshToken, { 38 | old_refresh_token: refresh_token.value, 39 | new_refresh_token_data: { 40 | account_id: account.id, 41 | refresh_token: new_refresh_token, 42 | expires_at: new Date(newRefreshExpiry()) 43 | } 44 | }) 45 | } catch (error) { 46 | return res.boom.badImplementation('Unable to set new refresh token') 47 | } 48 | 49 | const permission_variables = JSON.stringify(generatePermissionVariables(account)) 50 | 51 | const jwt_token = createHasuraJwt(account) 52 | const jwt_expires_in = newJwtExpiry 53 | const user: UserData = { 54 | id: account.user.id, 55 | display_name: account.user.display_name, 56 | email: account.email, 57 | avatar_url: account.user.avatar_url 58 | } 59 | const session: Session = { jwt_token, jwt_expires_in, user } 60 | if (refresh_token.type === 'cookie') { 61 | setCookie(res, new_refresh_token, permission_variables) 62 | } else { 63 | session.refresh_token = new_refresh_token 64 | } 65 | res.send(session) 66 | } 67 | 68 | export default asyncWrapper(refreshToken) 69 | -------------------------------------------------------------------------------- /src/routes/auth/token/revoke.ts: -------------------------------------------------------------------------------- 1 | import { Response } from 'express' 2 | import { asyncWrapper } from '@shared/helpers' 3 | import { deleteAllAccountRefreshTokens } from '@shared/queries' 4 | import { request } from '@shared/request' 5 | import { RequestExtended } from '@shared/types' 6 | 7 | async function revokeToken(req: RequestExtended, res: Response): Promise { 8 | if (!req.permission_variables) { 9 | return res.boom.unauthorized('Not logged in') 10 | } 11 | 12 | const { 'user-id': user_id } = req.permission_variables 13 | 14 | await request(deleteAllAccountRefreshTokens, { user_id }) 15 | 16 | return res.status(204).send() 17 | } 18 | 19 | export default asyncWrapper(revokeToken) 20 | -------------------------------------------------------------------------------- /src/routes/auth/token/token.test.ts: -------------------------------------------------------------------------------- 1 | import 'jest-extended' 2 | 3 | import { request } from '@test/server' 4 | import { end, saveJwt, saveRefreshToken, validJwt, validRefreshToken } from '@test/supertest-shared-utils' 5 | import { registerAccount, registerAndLoginAccount } from '@test/utils' 6 | 7 | it('should refresh the token', (done) => { 8 | registerAndLoginAccount(request).then(() => { 9 | request 10 | .get('/auth/token/refresh') 11 | .expect(200) 12 | .expect(validJwt()) 13 | .end(end(done)) 14 | }) 15 | }) 16 | 17 | it('should revoke the token', (done) => { 18 | registerAndLoginAccount(request).then(() => { 19 | request 20 | .post('/auth/token/revoke') 21 | .expect(204) 22 | .end(end(done)) 23 | }) 24 | }) 25 | 26 | describe('handle refresh tokens without cookies', () => { 27 | it('should refresh the token', (done) => { 28 | let refreshToken = '' 29 | 30 | registerAccount(request).then(({ email, password }) => { 31 | request 32 | .post('/auth/login') 33 | .send({ email, password, cookie: false }) 34 | .expect(200) 35 | .expect(validRefreshToken()) 36 | .expect(saveRefreshToken(r => refreshToken = r)) 37 | .end((err) => { 38 | if(err) return done(err) 39 | 40 | request 41 | .get('/auth/token/refresh') 42 | .query({ refresh_token: refreshToken }) 43 | .expect(200) 44 | .expect(validJwt()) 45 | .expect(validRefreshToken()) 46 | .end(end(done)) 47 | }) 48 | }) 49 | }) 50 | 51 | it('should revoke the token', (done) => { 52 | let jwtToken = '' 53 | 54 | registerAccount(request).then(({ email, password }) => { 55 | request 56 | .post('/auth/login') 57 | .send({ email, password, cookie: false }) 58 | .expect(200) 59 | .expect(validJwt()) 60 | .expect(saveJwt(j => jwtToken = j)) 61 | .end((err) => { 62 | if(err) return done(err) 63 | 64 | request 65 | .post('/auth/token/revoke') 66 | .set({ Authorization: `Bearer ${jwtToken}` }) 67 | .expect(204) 68 | .end(end(done)) 69 | }) 70 | }) 71 | }) 72 | }) 73 | -------------------------------------------------------------------------------- /src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import auth from './auth' 3 | import storage from './storage' 4 | import boom = require('express-boom') 5 | 6 | const router = Router() 7 | 8 | router.use(boom()) 9 | 10 | router.use('/auth', auth) 11 | 12 | router.use('/storage', storage) 13 | 14 | router.get('/healthz', (_req, res) => res.send('OK')) 15 | router.get('/version', (_req, res) => 16 | res.send(JSON.stringify({ version: 'v' + process.env.npm_package_version })) 17 | ) 18 | 19 | // THIS ENDPOINT IS ONLY TO BE USED FOR TESTS!! 20 | // It allows us to programmatically enable/disable 21 | // functionality needed for specific tests. 22 | if (process.env.NODE_ENV !== 'production') { 23 | router.post('/change-env', (req, res) => { 24 | Object.assign(process.env, req.body) 25 | res.json(req.body) 26 | }) 27 | } 28 | 29 | // all other routes should throw 404 not found 30 | router.use('*', (rwq, res) => { 31 | return res.boom.notFound() 32 | }) 33 | 34 | export default router 35 | -------------------------------------------------------------------------------- /src/routes/storage/delete.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Response } from 'express' 2 | import { 3 | PathConfig, 4 | createContext, 5 | getHeadObject, 6 | getKey, 7 | hasPermission, 8 | replaceMetadata 9 | } from './utils' 10 | 11 | import { STORAGE } from '@shared/config' 12 | import { s3 } from '@shared/s3' 13 | import { RequestExtended } from '@shared/types' 14 | 15 | export const deleteFile = async ( 16 | req: RequestExtended, 17 | res: Response, 18 | _next: NextFunction, 19 | rules: Partial, 20 | isMetadataRequest = false 21 | ): Promise => { 22 | const headObject = await getHeadObject(req) 23 | const context = createContext(req, headObject) 24 | 25 | if (!hasPermission([rules.delete, rules.write], context)) { 26 | return res.boom.forbidden() 27 | } 28 | 29 | if (isMetadataRequest) { 30 | // * Reset the object's metadata 31 | await replaceMetadata(req, false) 32 | } else { 33 | // * Delete the object, sharp 34 | const params = { 35 | Bucket: STORAGE.S3_BUCKET, 36 | Key: getKey(req) 37 | } 38 | try { 39 | await s3.deleteObject(params).promise() 40 | } catch (err) { 41 | return res.boom.badImplementation() 42 | } 43 | } 44 | return res.sendStatus(204) 45 | } 46 | -------------------------------------------------------------------------------- /src/routes/storage/index.ts: -------------------------------------------------------------------------------- 1 | import { OBJECT_PREFIX, META_PREFIX, STORAGE_RULES, PathConfig, containsSomeRule } from './utils' 2 | import { NextFunction, Response, Router } from 'express' 3 | import { deleteFile } from './delete' 4 | import { listGet } from './list_get' 5 | import { uploadFile } from './upload' 6 | import { RequestExtended } from '@shared/types' 7 | import { STORAGE } from '@shared/config' 8 | 9 | const router = Router() 10 | 11 | router.use((req, res, next) => { 12 | if (!STORAGE.ENABLED) { 13 | return res.boom.badImplementation( 14 | `Please set the STORAGE_ENABLEd env variable to true to use the storage routes.` 15 | ) 16 | } else { 17 | return next() 18 | } 19 | }) 20 | 21 | const createSecureMiddleware = ( 22 | fn: Function, 23 | rules: Partial, 24 | isMetadataRequest: boolean 25 | ) => (req: RequestExtended, res: Response, next: NextFunction): void => 26 | fn(req, res, next, rules, isMetadataRequest, rules.metadata).catch(next) 27 | 28 | const createRoutes = ( 29 | path: string, 30 | rules: Partial, 31 | isMetadataRequest = false 32 | ): Router => { 33 | const middleware = Router() 34 | 35 | // write, create, update 36 | if (containsSomeRule(rules, ['write', 'create', 'update'])) { 37 | middleware.post(path, createSecureMiddleware(uploadFile, rules, isMetadataRequest)) 38 | } 39 | 40 | // read, get, list 41 | if (containsSomeRule(rules, ['read', 'get', 'list'])) { 42 | middleware.get( 43 | path, 44 | (_, res, next) => { 45 | res.removeHeader('X-Frame-Options') 46 | next() 47 | }, 48 | createSecureMiddleware(listGet, rules, isMetadataRequest) 49 | ) 50 | } 51 | 52 | // write, delete 53 | if (containsSomeRule(rules, ['write', 'delete'])) { 54 | middleware.delete(path, createSecureMiddleware(deleteFile, rules, isMetadataRequest)) 55 | } 56 | 57 | return middleware 58 | } 59 | 60 | for (const path in STORAGE_RULES.paths) { 61 | const rules = STORAGE_RULES.paths[path] 62 | 63 | // create object data paths 64 | router.use(OBJECT_PREFIX, createRoutes(path, rules, false)) 65 | 66 | // create meta data paths 67 | router.use(META_PREFIX, createRoutes(path, rules, true)) 68 | } 69 | 70 | export default router 71 | -------------------------------------------------------------------------------- /src/routes/storage/list.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Response } from 'express' 2 | import { PathConfig, createContext, getKey, hasPermission } from './utils' 3 | 4 | import { STORAGE } from '@shared/config' 5 | import archiver from 'archiver' 6 | import { s3 } from '@shared/s3' 7 | import { RequestExtended } from '@shared/types' 8 | 9 | export const listFile = async ( 10 | req: RequestExtended, 11 | res: Response, 12 | _next: NextFunction, 13 | rules: Partial, 14 | isMetadataRequest = false 15 | ): Promise => { 16 | const key = getKey(req) 17 | const context = createContext(req) 18 | if (!hasPermission([rules.list, rules.read], context)) { 19 | return res.boom.forbidden() 20 | } 21 | const params = { 22 | Bucket: STORAGE.S3_BUCKET, 23 | Prefix: key.slice(0, -1) 24 | } 25 | const list = await s3.listObjectsV2(params).promise() 26 | 27 | if (list.Contents) { 28 | const headObjectsList = ( 29 | await Promise.all( 30 | list.Contents.map(async ({ Key }) => ({ 31 | key: Key as string, 32 | head: await s3 33 | .headObject({ 34 | Bucket: STORAGE.S3_BUCKET, 35 | Key: Key as string 36 | }) 37 | .promise() 38 | })) 39 | ) 40 | ).filter((resource) => hasPermission([rules.list, rules.read], createContext(req, resource))) 41 | 42 | if (isMetadataRequest) { 43 | return res.status(200).send( 44 | headObjectsList.map((entry) => { 45 | return { 46 | key: entry.key, 47 | ...entry.head 48 | } 49 | }) 50 | ) 51 | } else { 52 | const archive = archiver('zip') 53 | headObjectsList.forEach((entry) => { 54 | const objectStream = s3 55 | .getObject({ Bucket: STORAGE.S3_BUCKET, Key: entry.key }) 56 | .createReadStream() 57 | archive.append(objectStream, { name: entry.key }) 58 | }) 59 | res.attachment('list.zip').type('zip') 60 | archive.on('end', () => res.end()) 61 | archive.pipe(res) 62 | archive.finalize() 63 | } 64 | } else { 65 | // ? send an error instead? 66 | return res.status(200).send([]) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/routes/storage/list_get.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Response } from 'express' 2 | import { PathConfig, getKey } from './utils' 3 | import { getFile } from './get' 4 | import { listFile } from './list' 5 | import { RequestExtended } from '@shared/types' 6 | 7 | export const listGet = async ( 8 | req: RequestExtended, 9 | res: Response, 10 | _next: NextFunction, 11 | rules: Partial, 12 | isMetadataRequest = false 13 | ): Promise => { 14 | const key = getKey(req) 15 | 16 | // get dir 17 | if (key.endsWith('/')) { 18 | return listFile(req, res, _next, rules, isMetadataRequest) 19 | } 20 | 21 | // or get file 22 | return getFile(req, res, _next, rules, isMetadataRequest) 23 | } 24 | -------------------------------------------------------------------------------- /src/routes/storage/upload.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Response } from 'express' 2 | import { v4 as uuidv4 } from 'uuid' 3 | import { PathConfig, createContext, getHeadObject, getKey, hasPermission } from './utils' 4 | 5 | import { STORAGE } from '@shared/config' 6 | import { UploadedFile } from 'express-fileupload' 7 | import { s3 } from '@shared/s3' 8 | import { RequestExtended } from '@shared/types' 9 | import { fileMetadataUpdate } from '@shared/validation' 10 | 11 | export const uploadFile = async ( 12 | req: RequestExtended, 13 | res: Response, 14 | _next: NextFunction, 15 | rules: Partial, 16 | isMetadataRequest = false 17 | ): Promise => { 18 | const key = getKey(req) 19 | 20 | if (key.endsWith('/')) { 21 | return res.boom.forbidden(`Can't upload file that ends with /`) 22 | } 23 | 24 | const oldHeadObject = await getHeadObject(req, true) 25 | const isNew = !oldHeadObject 26 | 27 | if (isNew && !req.files?.file) { 28 | return res.boom.notFound() 29 | } 30 | 31 | const resource = req.files?.file as UploadedFile 32 | const context = createContext(req, resource) 33 | 34 | if (!isMetadataRequest) { 35 | if ( 36 | !hasPermission(isNew ? [rules.create, rules.write] : [rules.update, rules.write], context) 37 | ) { 38 | return res.boom.forbidden() 39 | } 40 | 41 | // * Create or update the object 42 | const upload_params = { 43 | Bucket: STORAGE.S3_BUCKET, 44 | Key: key, 45 | Body: resource.data, 46 | ContentType: resource.mimetype, 47 | Metadata: { 48 | token: oldHeadObject?.Metadata?.token || uuidv4() 49 | } 50 | } 51 | 52 | try { 53 | await s3.upload(upload_params).promise() 54 | } catch (err) { 55 | console.error('Fail to upload file') 56 | console.error({ upload_params }) 57 | console.error(err) 58 | return res.boom.badImplementation('Impossible to create or update the object.') 59 | } 60 | } else if (!isNew) { 61 | const { action } = await fileMetadataUpdate.validateAsync(req.body) 62 | 63 | if (action === 'revoke-token') { 64 | if (!hasPermission([], context)) { 65 | return res.boom.forbidden('incorrect x-access-token') 66 | } 67 | 68 | const key = getKey(req) 69 | const oldHeadObject = await getHeadObject(req, true) 70 | 71 | const updatedToken = uuidv4() 72 | 73 | // As S3 objects are immutable, we need to replace the entire object by its copy 74 | const params = { 75 | Bucket: STORAGE.S3_BUCKET, 76 | Key: key, 77 | CopySource: `${STORAGE.S3_BUCKET}/${key}`, 78 | ContentType: oldHeadObject?.ContentType, 79 | Metadata: { 80 | ...oldHeadObject?.Metadata, 81 | token: updatedToken 82 | }, 83 | MetadataDirective: 'REPLACE' 84 | } 85 | 86 | try { 87 | await s3.copyObject(params).promise() 88 | } catch (err) { 89 | console.error('error updating metadata') 90 | console.error(err) 91 | return res.boom.badImplementation('Impossible to update the object metadata.') 92 | } 93 | } else { 94 | return res.boom.notImplemented('Unknown metadata update') 95 | } 96 | } 97 | 98 | const headObject = await getHeadObject(req) 99 | return res.status(200).send({ key, ...headObject }) 100 | } 101 | -------------------------------------------------------------------------------- /src/routes/storage/utils.ts: -------------------------------------------------------------------------------- 1 | import safeEval, { FunctionFactory } from 'notevil' 2 | import { v4 as uuidv4 } from 'uuid' 3 | 4 | import { HeadObjectOutput } from 'aws-sdk/clients/s3' 5 | import { STORAGE } from '@shared/config' 6 | import fs from 'fs' 7 | import path from 'path' 8 | import { s3 } from '@shared/s3' 9 | import yaml from 'js-yaml' 10 | import { PermissionVariables, RequestExtended } from '@shared/types' 11 | 12 | export const OBJECT_PREFIX = '/o' 13 | export const META_PREFIX = '/m' 14 | export interface PathConfig { 15 | read: string 16 | write: string 17 | get: string 18 | list: string 19 | create: string 20 | update: string 21 | delete: string 22 | metadata?: { [key: string]: string } 23 | } 24 | 25 | interface StorageRules { 26 | functions?: { [key: string]: string | { params: string[]; code: string } } 27 | paths: { 28 | [key: string]: Partial 29 | } 30 | } 31 | 32 | interface StorageRequest { 33 | path: string 34 | query: unknown 35 | method: string 36 | params?: string 37 | auth?: PermissionVariables 38 | } 39 | 40 | export const containsSomeRule = ( 41 | rulesDefinition: Partial = {}, 42 | rules: (string | undefined)[] 43 | ): boolean => Object.keys(rulesDefinition).some((rule) => rules.includes(rule)) 44 | 45 | type StorageContext = { [key: string]: unknown } & { 46 | request: StorageRequest 47 | resource: object 48 | } 49 | 50 | let storageRules: StorageRules = { paths: {} } 51 | try { 52 | const fileContents = fs.readFileSync( 53 | path.resolve(process.env.PWD || '.', 'custom/storage-rules/rules.yaml'), 54 | 'utf8' 55 | ) 56 | try { 57 | storageRules = yaml.safeLoad(fileContents) as StorageRules 58 | } catch (e) { 59 | throw new Error('Custom storage security rules: invalid YAML file.') 60 | } 61 | } catch (e) { 62 | console.warn('No custom storage security rules found.') 63 | } 64 | 65 | export const STORAGE_RULES = storageRules 66 | 67 | // TODO allow functions to use other functions 68 | const storageFunctions = (context: object): { [key: string]: Function } => 69 | Object.entries(storageRules.functions || {}).reduce<{ 70 | [key: string]: Function 71 | }>((aggr, [name, value]) => { 72 | if (typeof value === 'string') { 73 | aggr[name] = FunctionFactory(context)('"use strict"; ' + value) 74 | } else { 75 | aggr[name] = FunctionFactory(context)(...value.params, '"use strict"; ' + value.code) 76 | } 77 | return aggr 78 | }, {}) 79 | 80 | export const createContext = ( 81 | req: RequestExtended, 82 | s3HeadObject: object = {} // TODO better s3 head object type 83 | ): object => { 84 | const auth = req.permission_variables 85 | 86 | const variables: StorageContext = { 87 | request: { 88 | path: req.path, 89 | method: req.method, 90 | query: req.query, 91 | auth 92 | }, 93 | ...req.params, 94 | resource: s3HeadObject 95 | } 96 | 97 | const functions = storageFunctions(variables) 98 | 99 | return { ...functions, ...variables, req } 100 | } 101 | 102 | export const hasPermission = (rules: (string | undefined)[], context: any): boolean => { 103 | return ( 104 | context.req.headers['x-admin-secret'] === process.env.HASURA_GRAPHQL_ADMIN_SECRET || 105 | rules.some((rule) => rule && !!safeEval(rule, context)) 106 | ) 107 | } 108 | 109 | export const generateMetadata = (metadataParams: object, context: object): object => 110 | Object.entries(metadataParams).reduce<{ [key: string]: unknown }>((aggr, [key, jsCode]) => { 111 | try { 112 | const value = safeEval(jsCode as string, context) 113 | if (value) { 114 | aggr[key] = value 115 | } 116 | } catch (err) { 117 | throw new Error(`Invalid formula for metadata key ${key}: '${jsCode}'`) 118 | } 119 | return aggr 120 | }, {}) 121 | 122 | // Creates an object key that is the path without the first character '/' 123 | export const getKey = (req: RequestExtended): string => req.path.substring(1) 124 | 125 | export const getHeadObject = async ( 126 | req: RequestExtended, 127 | ignoreErrors = false 128 | ): Promise => { 129 | const params = { 130 | Bucket: STORAGE.S3_BUCKET, 131 | Key: getKey(req) 132 | } 133 | try { 134 | return await s3.headObject(params).promise() 135 | } catch (err) { 136 | if (ignoreErrors) { 137 | return 138 | } 139 | throw new Error('Not found') 140 | } 141 | } 142 | 143 | export const replaceMetadata = async ( 144 | req: RequestExtended, 145 | keepOldMetadata: boolean, 146 | newMetadata: object = {} 147 | ): Promise => { 148 | const key = getKey(req) 149 | const oldHeadObject = await getHeadObject(req, true) 150 | 151 | // As S3 objects are immutable, we need to replace the entire object by its copy 152 | const params = { 153 | Bucket: STORAGE.S3_BUCKET, 154 | Key: key, 155 | CopySource: `${STORAGE.S3_BUCKET}/${key}`, 156 | ContentType: oldHeadObject?.ContentType, 157 | Metadata: { 158 | ...((keepOldMetadata && oldHeadObject?.Metadata) || { 159 | token: uuidv4() 160 | }), 161 | ...newMetadata 162 | }, 163 | MetadataDirective: 'REPLACE' 164 | } 165 | try { 166 | await s3.copyObject(params).promise() 167 | } catch (err) { 168 | throw new Error('Impossible to update the object metadata.') 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import { COOKIES } from '@shared/config' 2 | import cookieParser from 'cookie-parser' 3 | import cors from 'cors' 4 | import { errors } from './errors' 5 | import express from 'express' 6 | import fileUpload from 'express-fileupload' 7 | import helmet from 'helmet' 8 | import { json } from 'body-parser' 9 | import morgan from 'morgan' 10 | import { limiter } from './limiter' 11 | import router from './routes' 12 | import passport from 'passport' 13 | import { authMiddleware } from './middlewares/auth' 14 | 15 | const app = express() 16 | 17 | if (process.env.NODE_ENV === 'production') { 18 | app.set('trust proxy', 1) 19 | app.use(limiter) 20 | } 21 | 22 | app.use( 23 | morgan( 24 | ':remote-addr - :remote-user [:date[clf]] ":method :url HTTP/:http-version" :status :res[content-length]' 25 | ) 26 | ) 27 | app.use(helmet()) 28 | app.use(json()) 29 | app.use(cors({ credentials: true, origin: true })) 30 | app.use(fileUpload()) 31 | 32 | app.use(passport.initialize()) 33 | 34 | /** 35 | * Set a cookie secret to enable server validation of cookies. 36 | */ 37 | if (COOKIES.SECRET) { 38 | app.use(cookieParser(COOKIES.SECRET)) 39 | } else { 40 | app.use(cookieParser()) 41 | } 42 | 43 | app.use(authMiddleware) 44 | app.use(router) 45 | app.use(errors) 46 | 47 | export { app } 48 | -------------------------------------------------------------------------------- /src/shared/config/application.ts: -------------------------------------------------------------------------------- 1 | import { castIntEnv, returnBooleanEnvVar } from './utils' 2 | 3 | /** 4 | * * Application Settings 5 | */ 6 | export const APPLICATION = { 7 | get SERVER_URL() { 8 | return process.env.SERVER_URL || '' 9 | }, 10 | get REDIRECT_URL_ERROR() { 11 | return process.env.REDIRECT_URL_ERROR || '' 12 | }, 13 | get REDIRECT_URL_SUCCESS() { 14 | return process.env.REDIRECT_URL_SUCCESS || '' 15 | }, 16 | get HASURA_GRAPHQL_ADMIN_SECRET() { 17 | return process.env.HASURA_GRAPHQL_ADMIN_SECRET || '' 18 | }, 19 | get HASURA_ENDPOINT() { 20 | return process.env.HASURA_ENDPOINT || '' 21 | }, 22 | 23 | get HOST() { 24 | return process.env.HOST || '' 25 | }, 26 | get PORT() { 27 | return castIntEnv('PORT', 3000) 28 | }, 29 | 30 | get SMTP_PASS() { 31 | return process.env.SMTP_PASS || '' 32 | }, 33 | get SMTP_HOST() { 34 | return process.env.SMTP_HOST || '' 35 | }, 36 | get SMTP_USER() { 37 | return process.env.SMTP_USER || '' 38 | }, 39 | get SMTP_SENDER() { 40 | return process.env.SMTP_SENDER || this.SMTP_USER 41 | }, 42 | get SMTP_AUTH_METHOD() { 43 | return process.env.SMTP_AUTH_METHOD || 'PLAIN' 44 | }, 45 | get EMAILS_ENABLE() { 46 | return returnBooleanEnvVar(['EMAILS_ENABLE', 'EMAILS_ENABLED'], false) 47 | }, 48 | get SMTP_PORT() { 49 | return castIntEnv('SMTP_PORT', 587) 50 | }, 51 | get SMTP_SECURE() { 52 | return returnBooleanEnvVar(['SMTP_SECURE'], true) // note: false disables SSL (deprecated) 53 | }, 54 | 55 | get MAX_REQUESTS() { 56 | return castIntEnv('MAX_REQUESTS', 1000) 57 | }, 58 | get TIME_FRAME() { 59 | return castIntEnv('TIME_FRAME', 15 * 60 * 1000) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/shared/config/authentication/cookies.ts: -------------------------------------------------------------------------------- 1 | import { castBooleanEnv } from '../utils' 2 | 3 | export const COOKIES = { 4 | get SECRET() { 5 | return process.env.COOKIE_SECRET || '' 6 | }, 7 | get SECURE() { 8 | return castBooleanEnv('COOKIE_SECURE') 9 | }, 10 | get SAME_SITE() { 11 | const sameSiteEnv = process.env.COOKIE_SAME_SITE?.toLowerCase() 12 | 13 | let sameSite: boolean | 'lax' | 'strict' | 'none' = 'lax' 14 | if (sameSiteEnv) { 15 | if (['true', 'false'].includes(sameSiteEnv)) { 16 | sameSite = Boolean(sameSiteEnv) 17 | } else if (sameSiteEnv === 'lax' || sameSiteEnv === 'strict' || sameSiteEnv === 'none') { 18 | sameSite = sameSiteEnv 19 | } 20 | } 21 | 22 | return sameSite 23 | } 24 | } -------------------------------------------------------------------------------- /src/shared/config/authentication/index.ts: -------------------------------------------------------------------------------- 1 | import { returnBooleanEnvVar } from '../utils' 2 | 3 | export * from './registration' 4 | export * from './jwt' 5 | export * from './providers' 6 | export * from './mfa' 7 | export * from './cookies' 8 | 9 | /** 10 | * * Authentication settings 11 | */ 12 | export const AUTHENTICATION = { 13 | get ENABLED() { 14 | return returnBooleanEnvVar(['AUTH_ENABLE', 'AUTH_ENABLED'], true) 15 | }, 16 | get AUTH_LOCAL_USERS_ENABLED() { 17 | return returnBooleanEnvVar(['AUTH_LOCAL_USERS_ENABLE', 'AUTH_LOCAL_USERS_ENABLED'], true) 18 | }, 19 | get CHANGE_EMAIL_ENABLED() { 20 | return returnBooleanEnvVar(['CHANGE_EMAIL_ENABLE', 'CHANGE_EMAIL_ENABLED'], true) 21 | }, 22 | get NOTIFY_EMAIL_CHANGE() { 23 | return returnBooleanEnvVar(['NOTIFY_EMAIL_CHANGE', 'NOTIFY_EMAIL_CHANGE'], false) 24 | }, 25 | get ANONYMOUS_USERS_ENABLED() { 26 | return returnBooleanEnvVar(['ANONYMOUS_USERS_ENABLE', 'ANONYMOUS_USERS_ENABLED'], false) 27 | }, 28 | get ALLOW_USER_SELF_DELETE() { 29 | return returnBooleanEnvVar(['ALLOW_USER_SELF_DELETE'], false) 30 | }, 31 | get VERIFY_EMAILS() { 32 | return returnBooleanEnvVar(['VERIFY_EMAILS'], false) 33 | }, 34 | get LOST_PASSWORD_ENABLED() { 35 | return returnBooleanEnvVar(['LOST_PASSWORD_ENABLE', 'LOST_PASSWORD_ENABLED'], false) 36 | }, 37 | get USER_IMPERSONATION_ENABLED() { 38 | return returnBooleanEnvVar(['USER_IMPERSONATION_ENABLE', 'USER_IMPERSONATION_ENABLED'], false) 39 | }, 40 | get MAGIC_LINK_ENABLED() { 41 | return returnBooleanEnvVar(['MAGIC_LINK_ENABLE', 'MAGIC_LINK_ENABLED'], false) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/shared/config/authentication/jwt.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { castIntEnv, castStringArrayEnv } from '../utils' 3 | 4 | /** 5 | * * Authentication settings 6 | */ 7 | export const JWT = { 8 | get KEY() { 9 | return process.env.JWT_KEY?.replace(/\\n/g, '\n') || '' 10 | }, 11 | get ALGORITHM() { 12 | return process.env.JWT_ALGORITHM || 'RS256' 13 | }, 14 | get CLAIMS_NAMESPACE() { 15 | return process.env.JWT_CLAIMS_NAMESPACE || 'https://hasura.io/jwt/claims' 16 | }, 17 | get KEY_FILE_PATH() { 18 | return path.resolve(process.env.PWD || '.', 'custom/keys/private.pem') 19 | }, 20 | get EXPIRES_IN() { 21 | return castIntEnv('JWT_EXPIRES_IN', 15) 22 | }, 23 | get REFRESH_EXPIRES_IN() { 24 | return castIntEnv('JWT_REFRESH_EXPIRES_IN', 43200) 25 | }, 26 | get CUSTOM_FIELDS() { 27 | return castStringArrayEnv('JWT_CUSTOM_FIELDS') 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/shared/config/authentication/mfa.ts: -------------------------------------------------------------------------------- 1 | import { returnBooleanEnvVar } from '../utils' 2 | 3 | // Multi-Factor Authentication configuration 4 | export const MFA = { 5 | get ENABLED() { 6 | return returnBooleanEnvVar(['MFA_ENABLE', 'MFA_ENABLED'], true) 7 | }, 8 | get OTP_ISSUER() { 9 | return process.env.OTP_ISSUER || 'HBP' 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/shared/config/authentication/providers.ts: -------------------------------------------------------------------------------- 1 | import { returnBooleanEnvVar } from '../utils' 2 | import { APPLICATION } from '../application' 3 | 4 | const PROVIDERS = { 5 | get REDIRECT_SUCCESS() { 6 | return process.env.PROVIDER_SUCCESS_REDIRECT || APPLICATION.REDIRECT_URL_SUCCESS 7 | }, 8 | get REDIRECT_FAILURE() { 9 | return process.env.PROVIDER_FAILURE_REDIRECT || APPLICATION.REDIRECT_URL_ERROR 10 | }, 11 | 12 | get github() { 13 | return !returnBooleanEnvVar(['GITHUB_ENABLE', 'GITHUB_ENABLED'], false) 14 | ? null 15 | : { 16 | get clientID() { 17 | return process.env.GITHUB_CLIENT_ID 18 | }, 19 | get clientSecret() { 20 | return process.env.GITHUB_CLIENT_SECRET 21 | }, 22 | get authorizationURL() { 23 | return process.env.GITHUB_AUTHORIZATION_URL 24 | }, 25 | get tokenURL() { 26 | return process.env.GITHUB_TOKEN_URL 27 | }, 28 | get userProfileURL() { 29 | return process.env.GITHUB_USER_PROFILE_URL 30 | } 31 | } 32 | }, 33 | 34 | get google() { 35 | return !returnBooleanEnvVar(['GOOGLE_ENABLE', 'GOOGLE_ENABLED'], false) 36 | ? null 37 | : { 38 | get clientID() { 39 | return process.env.GOOGLE_CLIENT_ID || '' 40 | }, 41 | get clientSecret() { 42 | return process.env.GOOGLE_CLIENT_SECRET || '' 43 | } 44 | } 45 | }, 46 | 47 | get facebook() { 48 | return !returnBooleanEnvVar(['FACEBOOK_ENABLE', 'FACEBOOK_ENABLED'], false) 49 | ? null 50 | : { 51 | get clientID() { 52 | return process.env.FACEBOOK_CLIENT_ID || '' 53 | }, 54 | get clientSecret() { 55 | return process.env.FACEBOOK_CLIENT_SECRET || '' 56 | } 57 | } 58 | }, 59 | 60 | get twitter() { 61 | return !returnBooleanEnvVar(['TWITTER_ENABLE', 'TWITTER_ENABLED'], false) 62 | ? null 63 | : { 64 | get consumerKey() { 65 | return process.env.TWITTER_CONSUMER_KEY || '' 66 | }, 67 | get consumerSecret() { 68 | return process.env.TWITTER_CONSUMER_SECRET || '' 69 | } 70 | } 71 | }, 72 | 73 | get linkedin() { 74 | return !returnBooleanEnvVar(['LINKEDIN_ENABLE', 'LINKEDIN_ENABLED'], false) 75 | ? null 76 | : { 77 | get clientID() { 78 | return process.env.LINKEDIN_CLIENT_ID || '' 79 | }, 80 | get clientSecret() { 81 | return process.env.LINKEDIN_CLIENT_SECRET || '' 82 | } 83 | } 84 | }, 85 | 86 | get apple() { 87 | if (!returnBooleanEnvVar(['APPLE_ENABLE', 'APPLE_ENABLED'], false)) return null 88 | try { 89 | return { 90 | get clientID() { 91 | return process.env.APPLE_CLIENT_ID || '' 92 | }, 93 | get teamID() { 94 | return process.env.APPLE_TEAM_ID || '' 95 | }, 96 | get keyID() { 97 | return process.env.APPLE_KEY_ID || '' 98 | }, 99 | get key() { 100 | return ( 101 | (process.env.APPLE_PRIVATE_KEY && 102 | // Convert contents from base64 string to string to avoid issues with line breaks in the environment variable 103 | Buffer.from(process.env.APPLE_PRIVATE_KEY, 'base64').toString('ascii')) || 104 | '' 105 | ) 106 | } 107 | } 108 | } catch (e) { 109 | throw new Error(`Invalid Apple OAuth Key file.`) 110 | } 111 | }, 112 | 113 | get windowslive() { 114 | return !returnBooleanEnvVar(['WINDOWS_LIVE_ENABLE', 'WINDOWS_LIVE_ENABLED'], false) 115 | ? null 116 | : { 117 | get clientID() { 118 | return process.env.WINDOWS_LIVE_CLIENT_ID || '' 119 | }, 120 | get clientSecret() { 121 | return process.env.WINDOWS_LIVE_CLIENT_SECRET || '' 122 | } 123 | } 124 | }, 125 | 126 | get spotify() { 127 | return !returnBooleanEnvVar(['SPOTIFY_ENABLE', 'SPOTIFY_ENABLE'], false) 128 | ? null 129 | : { 130 | get clientID() { 131 | return process.env.SPOTIFY_CLIENT_ID || '' 132 | }, 133 | get clientSecret() { 134 | return process.env.SPOTIFY_CLIENT_SECRET || '' 135 | } 136 | } 137 | } 138 | } 139 | 140 | export { PROVIDERS } 141 | -------------------------------------------------------------------------------- /src/shared/config/authentication/registration.ts: -------------------------------------------------------------------------------- 1 | import { castBooleanEnv, castStringArrayEnv, castIntEnv, returnBooleanEnvVar } from '../utils' 2 | 3 | /** 4 | * * Registration settings 5 | */ 6 | export const REGISTRATION = { 7 | get ALLOWED_EMAIL_DOMAINS() { 8 | return process.env.ALLOWED_EMAIL_DOMAINS 9 | }, 10 | get DEFAULT_USER_ROLE() { 11 | return process.env.DEFAULT_USER_ROLE || 'user' 12 | }, 13 | get DEFAULT_ANONYMOUS_ROLE() { 14 | return process.env.DEFAULT_ANONYMOUS_ROLE || 'anonymous' 15 | }, 16 | get AUTO_ACTIVATE_NEW_USERS() { 17 | return castBooleanEnv('AUTO_ACTIVATE_NEW_USERS', true) 18 | }, 19 | get HIBP_ENABLED() { 20 | return returnBooleanEnvVar(['HIBP_ENABLE', 'HIBP_ENABLED'], false) 21 | }, 22 | get CUSTOM_FIELDS() { 23 | return castStringArrayEnv('REGISTRATION_CUSTOM_FIELDS') 24 | }, 25 | get MIN_PASSWORD_LENGTH() { 26 | return castIntEnv('MIN_PASSWORD_LENGTH', 3) 27 | }, 28 | get DEFAULT_ALLOWED_USER_ROLES() { 29 | return castStringArrayEnv('DEFAULT_ALLOWED_USER_ROLES', [this.DEFAULT_USER_ROLE]) 30 | }, 31 | get ALLOWED_USER_ROLES() { 32 | return castStringArrayEnv('ALLOWED_USER_ROLES', this.DEFAULT_ALLOWED_USER_ROLES) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/shared/config/headers.ts: -------------------------------------------------------------------------------- 1 | // Headers 2 | export const HEADERS = { 3 | ADMIN_SECRET_HEADER: 'x-admin-secret' 4 | } -------------------------------------------------------------------------------- /src/shared/config/index.ts: -------------------------------------------------------------------------------- 1 | // ! Keep dotent.config at the very beginning of the file!!! 2 | import dotenv from 'dotenv' 3 | // Load '.env' file if production mode, '.env.' otherwise 4 | const envFile = 5 | process.env.NODE_ENV && process.env.NODE_ENV !== 'production' 6 | ? `.env.${process.env.NODE_ENV}` 7 | : '.env' 8 | dotenv.config({ path: envFile }) 9 | 10 | import { APPLICATION } from './application' 11 | export * from './application' 12 | export * from './authentication' 13 | export * from './storage' 14 | export * from './headers'; 15 | 16 | /** 17 | * * Check required settings, and raise an error if some are missing. 18 | */ 19 | if (!APPLICATION.HASURA_ENDPOINT) { 20 | throw new Error('No Hasura GraphQL endpoint found.') 21 | } 22 | -------------------------------------------------------------------------------- /src/shared/config/storage.ts: -------------------------------------------------------------------------------- 1 | import { returnBooleanEnvVar } from './utils' 2 | 3 | /** 4 | * * Storage Settings 5 | */ 6 | export const STORAGE = { 7 | get ENABLED() { 8 | return returnBooleanEnvVar(['STORAGE_ENABLE', 'STORAGE_ENABLED'], true) 9 | }, 10 | get S3_SSL_ENABLED() { 11 | return returnBooleanEnvVar(['S3_SSL_ENABLE', 'S3_SSL_ENABLED'], true) 12 | }, 13 | get S3_BUCKET() { 14 | return process.env.S3_BUCKET || '' 15 | }, 16 | get S3_ENDPOINT() { 17 | return process.env.S3_ENDPOINT || '' 18 | }, 19 | get S3_ACCESS_KEY_ID() { 20 | return process.env.S3_ACCESS_KEY_ID || '' 21 | }, 22 | get S3_SECRET_ACCESS_KEY() { 23 | return process.env.S3_SECRET_ACCESS_KEY || '' 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/shared/config/utils.ts: -------------------------------------------------------------------------------- 1 | // import { env } from 'process' 2 | 3 | // * Helpers for casting environment variables 4 | export const castBooleanEnv = (envVar: string, defaultValue = false): boolean => { 5 | if (process.env[envVar] !== undefined && envVar.endsWith('ENABLE')) { 6 | console.warn(process.env[envVar]) 7 | console.warn(`Please update ${envVar} to ${envVar.replace('ENABLE', 'ENABLED')}`) 8 | } 9 | return process.env[envVar] ? process.env[envVar]?.toLowerCase() === 'true' : defaultValue 10 | } 11 | 12 | export const castIntEnv = (envVar: string, defaultValue: number): number => { 13 | const n = parseInt(process.env[envVar] as string, 10) 14 | 15 | if (isNaN(n)) { 16 | return defaultValue 17 | } 18 | 19 | return n 20 | } 21 | 22 | export const castStringArrayEnv = (envVar: string, defaultValue: string[] = []): string[] => 23 | process.env[envVar]?.length 24 | ? (process.env[envVar] as string).split(',').map((field) => field.trim()) 25 | : defaultValue 26 | 27 | export const envExists = (envVar: string): boolean => process.env[envVar] !== undefined 28 | 29 | export const returnBooleanEnvVar = (envVars: string[], defaultValue: boolean) => { 30 | for (const i in envVars) { 31 | const envVar = envVars[i] 32 | if (envExists(envVar)) { 33 | return castBooleanEnv(envVar) 34 | } 35 | } 36 | 37 | return defaultValue 38 | } 39 | -------------------------------------------------------------------------------- /src/shared/cookies.ts: -------------------------------------------------------------------------------- 1 | import { COOKIES, JWT } from './config' 2 | 3 | import { Response } from 'express' 4 | import { insertRefreshToken } from './queries' 5 | import { request } from './request' 6 | import { v4 as uuidv4 } from 'uuid' 7 | import { AccountData } from './types' 8 | import { generatePermissionVariables } from './jwt' 9 | 10 | interface InsertRefreshTokenData { 11 | insert_auth_refresh_tokens_one: { 12 | account: AccountData 13 | } 14 | } 15 | 16 | /** 17 | * New refresh token expiry date. 18 | */ 19 | export function newRefreshExpiry(): number { 20 | const now = new Date() 21 | const days = JWT.REFRESH_EXPIRES_IN / 1440 22 | 23 | return now.setDate(now.getDate() + days) 24 | } 25 | 26 | /** 27 | * Set refresh token as a cookie 28 | * @param res Express Response 29 | * @param refresh_token Refresh token to be set 30 | */ 31 | export const setCookie = ( 32 | res: Response, 33 | refresh_token: string, 34 | permission_variables: string 35 | ): void => { 36 | // converting JWT_REFRESH_EXPIRES_IN from minutes to milliseconds 37 | const maxAge = JWT.REFRESH_EXPIRES_IN * 60 * 1000 38 | 39 | // set refresh token as cookie 40 | res.cookie('refresh_token', refresh_token, { 41 | httpOnly: true, 42 | maxAge, 43 | signed: Boolean(COOKIES.SECRET), 44 | sameSite: COOKIES.SAME_SITE, 45 | secure: COOKIES.SECURE 46 | }) 47 | 48 | // set permission variables cookie 49 | res.cookie('permission_variables', permission_variables, { 50 | httpOnly: true, 51 | maxAge, 52 | signed: Boolean(COOKIES.SECRET), 53 | sameSite: COOKIES.SAME_SITE, 54 | secure: COOKIES.SECURE 55 | }) 56 | } 57 | 58 | /** 59 | * Insert new refresh token in database and maybe set new refresh token as cookie. 60 | * @param res Express Response 61 | * @param accountId Account ID 62 | * @param useCookie (optional) if the cookie should be set or not 63 | * @param refresh_token (optional) Refresh token to be set 64 | */ 65 | export const setRefreshToken = async ( 66 | res: Response, 67 | accountId: string, 68 | useCookie = false, 69 | refresh_token?: string 70 | ): Promise => { 71 | if (!refresh_token) { 72 | refresh_token = uuidv4() 73 | } 74 | 75 | const insert_account_data = (await request(insertRefreshToken, { 76 | refresh_token_data: { 77 | account_id: accountId, 78 | refresh_token, 79 | expires_at: new Date(newRefreshExpiry()) 80 | } 81 | })) as InsertRefreshTokenData 82 | 83 | if (useCookie) { 84 | const { account } = insert_account_data.insert_auth_refresh_tokens_one 85 | const permission_variables = JSON.stringify(generatePermissionVariables(account)) 86 | setCookie(res, refresh_token, permission_variables) 87 | } else { 88 | res.clearCookie('refresh_token') 89 | res.clearCookie('permission_variables') 90 | } 91 | 92 | return refresh_token 93 | } 94 | -------------------------------------------------------------------------------- /src/shared/email.ts: -------------------------------------------------------------------------------- 1 | import { APPLICATION } from '@shared/config' 2 | 3 | import Email from 'email-templates' 4 | import nodemailer from 'nodemailer' 5 | import path from 'path' 6 | 7 | /** 8 | * SMTP transport. 9 | */ 10 | const transport = nodemailer.createTransport({ 11 | host: APPLICATION.SMTP_HOST, 12 | port: Number(APPLICATION.SMTP_PORT), 13 | secure: Boolean(APPLICATION.SMTP_SECURE), 14 | auth: { 15 | pass: APPLICATION.SMTP_PASS, 16 | user: APPLICATION.SMTP_USER 17 | }, 18 | authMethod: APPLICATION.SMTP_AUTH_METHOD 19 | }) 20 | 21 | /** 22 | * Reusable email client. 23 | */ 24 | export const emailClient = new Email({ 25 | transport, 26 | message: { from: APPLICATION.SMTP_SENDER }, 27 | send: APPLICATION.EMAILS_ENABLE, 28 | views: { 29 | root: path.resolve(process.env.PWD || '.', 'custom/emails'), 30 | options: { 31 | extension: 'ejs' 32 | } 33 | } 34 | }) 35 | -------------------------------------------------------------------------------- /src/shared/helpers.ts: -------------------------------------------------------------------------------- 1 | import { COOKIES, REGISTRATION } from './config' 2 | import { NextFunction, Response } from 'express' 3 | import { 4 | rotateTicket as rotateTicketQuery, 5 | selectAccountByEmail as selectAccountByEmailQuery, 6 | selectAccountByTicket as selectAccountByTicketQuery, 7 | selectAccountByUserId as selectAccountByUserIdQuery 8 | } from './queries' 9 | 10 | import QRCode from 'qrcode' 11 | import bcrypt from 'bcryptjs' 12 | import { pwnedPassword } from 'hibp' 13 | import { request } from './request' 14 | import { v4 as uuidv4 } from 'uuid' 15 | import { AccountData, QueryAccountData, PermissionVariables, RequestExtended } from './types' 16 | 17 | /** 18 | * Create QR code. 19 | * @param secret Required OTP secret. 20 | */ 21 | export async function createQR(secret: string): Promise { 22 | try { 23 | return await QRCode.toDataURL(secret) 24 | } catch (err) { 25 | throw new Error('Could not create QR code') 26 | } 27 | } 28 | 29 | /** 30 | * This wrapper function sends any route errors to `next()`. 31 | */ 32 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 33 | export function asyncWrapper(fn: any) { 34 | return function (req: RequestExtended, res: Response, next: NextFunction): void { 35 | fn(req, res, next).catch(next) 36 | } 37 | } 38 | 39 | export const selectAccountByEmail = async (email: string): Promise => { 40 | const hasuraData = await request(selectAccountByEmailQuery, { email }) 41 | if (!hasuraData.auth_accounts[0]) throw new Error('Account does not exist.') 42 | return hasuraData.auth_accounts[0] 43 | } 44 | 45 | export const selectAccountByTicket = async (ticket: string): Promise => { 46 | const hasuraData = await request(selectAccountByTicketQuery, { 47 | ticket, 48 | now: new Date() 49 | }) 50 | if (!hasuraData.auth_accounts[0]) throw new Error('Account does not exist.') 51 | return hasuraData.auth_accounts[0] 52 | } 53 | 54 | // TODO await request returns undefined if no user found! 55 | export const selectAccountByUserId = async (user_id: string | undefined): Promise => { 56 | if (!user_id) { 57 | throw new Error('Invalid User Id.') 58 | } 59 | const hasuraData = await request(selectAccountByUserIdQuery, { user_id }) 60 | if (!hasuraData.auth_accounts[0]) throw new Error('Account does not exist.') 61 | return hasuraData.auth_accounts[0] 62 | } 63 | 64 | /** 65 | * Looks for an account in the database, first by email, second by ticket 66 | * @param httpBody 67 | * @return account data, null if account is not found 68 | */ 69 | export const selectAccount = async (httpBody: { 70 | [key: string]: string 71 | }): Promise => { 72 | const { email, ticket } = httpBody 73 | try { 74 | return await selectAccountByEmail(email) 75 | } catch { 76 | if (!ticket) { 77 | return undefined 78 | } 79 | try { 80 | return await selectAccountByTicket(ticket) 81 | } catch { 82 | return undefined 83 | } 84 | } 85 | } 86 | 87 | /** 88 | * Password hashing function. 89 | * @param password Password to hash. 90 | */ 91 | export const hashPassword = async (password: string): Promise => { 92 | try { 93 | return await bcrypt.hash(password, 10) 94 | } catch (err) { 95 | throw new Error('Could not hash password') 96 | } 97 | } 98 | 99 | /** 100 | * Checks password against the HIBP API. 101 | * @param password Password to check. 102 | */ 103 | export const checkHibp = async (password: string): Promise => { 104 | if (REGISTRATION.HIBP_ENABLED && (await pwnedPassword(password))) { 105 | throw new Error('Password is too weak.') 106 | } 107 | } 108 | 109 | export const rotateTicket = async (ticket: string): Promise => { 110 | const new_ticket = uuidv4() 111 | await request(rotateTicketQuery, { 112 | ticket, 113 | now: new Date(), 114 | new_ticket 115 | }) 116 | return new_ticket 117 | } 118 | 119 | export const getPermissionVariablesFromCookie = (req: RequestExtended): PermissionVariables => { 120 | const { permission_variables } = COOKIES.SECRET ? req.signedCookies : req.cookies 121 | if (!permission_variables) throw new Error('No permission variables') 122 | return JSON.parse(permission_variables) 123 | } 124 | 125 | export const getEndURLOperator = ({ url }: { url: string }) => { 126 | return url.includes('?') ? '&' : '?' 127 | } 128 | -------------------------------------------------------------------------------- /src/shared/jwt.ts: -------------------------------------------------------------------------------- 1 | import { JWT as CONFIG_JWT, REGISTRATION } from './config' 2 | import { JWK, JWKS, JWT } from 'jose' 3 | 4 | import fs from 'fs' 5 | import kebabCase from 'lodash.kebabcase' 6 | import { Claims, Token, AccountData, ClaimValueType } from './types' 7 | 8 | const RSA_TYPES = ['RS256', 'RS384', 'RS512'] 9 | const SHA_TYPES = ['HS256', 'HS384', 'HS512'] 10 | 11 | let jwtKey: string | JWK.RSAKey | JWK.ECKey | JWK.OKPKey | JWK.OctKey = CONFIG_JWT.KEY 12 | 13 | /** 14 | * * Sets the JWT Key. 15 | * * If RSA algorithm, then checks if the PEM has been passed on through the JWT_KEY 16 | * * If not, tries to read the private.pem file, or generates it otherwise 17 | * * If SHA algorithm, then uses the JWT_KEY environment variables 18 | */ 19 | if (RSA_TYPES.includes(CONFIG_JWT.ALGORITHM)) { 20 | if (jwtKey) { 21 | try { 22 | jwtKey = JWK.asKey(jwtKey, { alg: CONFIG_JWT.ALGORITHM }) 23 | jwtKey.toPEM(true) 24 | } catch (error) { 25 | throw new Error('Invalid RSA private key in the JWT_KEY environment variable.') 26 | } 27 | } else { 28 | try { 29 | const file = fs.readFileSync(CONFIG_JWT.KEY_FILE_PATH) 30 | jwtKey = JWK.asKey(file) 31 | } catch (error) { 32 | jwtKey = JWK.generateSync('RSA', 2048, { alg: CONFIG_JWT.ALGORITHM, use: 'sig' }, true) 33 | fs.writeFileSync(CONFIG_JWT.KEY_FILE_PATH, jwtKey.toPEM(true)) 34 | } 35 | } 36 | } else if (SHA_TYPES.includes(CONFIG_JWT.ALGORITHM)) { 37 | if (!jwtKey) { 38 | throw new Error('Empty JWT secret key.') 39 | } 40 | } else { 41 | throw new Error(`Invalid JWT algorithm: ${CONFIG_JWT.ALGORITHM}`) 42 | } 43 | 44 | export const newJwtExpiry = CONFIG_JWT.EXPIRES_IN * 60 * 1000 45 | 46 | /** 47 | * Convert array to Postgres array 48 | * @param arr js array to be converted to Postgres array 49 | */ 50 | function toPgArray(arr: string[]): string { 51 | const m = arr.map((e) => `"${e}"`).join(',') 52 | return `{${m}}` 53 | } 54 | 55 | /** 56 | * Create an object that contains all the permission variables of the user, 57 | * i.e. user-id, allowed-roles, default-role and the kebab-cased columns 58 | * of the public.tables columns defined in JWT_CUSTOM_FIELDS 59 | * @param jwt if true, add a 'x-hasura-' prefix to the property names, and stringifies the values (required by Hasura) 60 | */ 61 | export function generatePermissionVariables( 62 | { default_role, account_roles = [], user }: AccountData, 63 | jwt = false 64 | ): { [key: string]: ClaimValueType } { 65 | const prefix = jwt ? 'x-hasura-' : '' 66 | const role = user.is_anonymous 67 | ? REGISTRATION.DEFAULT_ANONYMOUS_ROLE 68 | : default_role || REGISTRATION.DEFAULT_USER_ROLE 69 | const accountRoles = account_roles.map(({ role: roleName }) => roleName) 70 | 71 | if (!accountRoles.includes(role)) { 72 | accountRoles.push(role) 73 | } 74 | 75 | return { 76 | [`${prefix}user-id`]: user.id, 77 | [`${prefix}allowed-roles`]: accountRoles, 78 | [`${prefix}default-role`]: role, 79 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 80 | ...CONFIG_JWT.CUSTOM_FIELDS.reduce<{ [key: string]: ClaimValueType }>((aggr: any, cursor) => { 81 | const type = typeof user[cursor] as ClaimValueType 82 | 83 | let value 84 | if (type === 'string') { 85 | value = user[cursor] 86 | } else if (Array.isArray(user[cursor])) { 87 | value = toPgArray(user[cursor] as string[]) 88 | } else { 89 | value = JSON.stringify(user[cursor] ?? null) 90 | } 91 | 92 | aggr[`${prefix}${kebabCase(cursor)}`] = value 93 | 94 | return aggr 95 | }, {}) 96 | } 97 | } 98 | 99 | /** 100 | * * Creates a JWKS store. Only works with RSA algorithms. Raises an error otherwise 101 | * @returns JWKS store 102 | */ 103 | export const getJwkStore = (): JWKS.KeyStore => { 104 | if (RSA_TYPES.includes(CONFIG_JWT.ALGORITHM)) { 105 | const keyStore = new JWKS.KeyStore() 106 | keyStore.add(jwtKey as JWK.RSAKey) 107 | return keyStore 108 | } 109 | throw new Error('JWKS is not implemented on this server.') 110 | } 111 | 112 | /** 113 | * * Signs a payload with the existing JWT configuration 114 | */ 115 | export const sign = (payload: object, accountData: AccountData): string => 116 | JWT.sign(payload, jwtKey, { 117 | algorithm: CONFIG_JWT.ALGORITHM, 118 | expiresIn: `${CONFIG_JWT.EXPIRES_IN}m`, 119 | subject: accountData.user.id, 120 | issuer: 'nhost' 121 | }) 122 | 123 | /** 124 | * Verify JWT token and return the Hasura claims. 125 | * @param authorization Authorization header. 126 | */ 127 | export const getClaims = (authorization: string | undefined): Claims => { 128 | if (!authorization) throw new Error('Missing Authorization header.') 129 | const token = authorization.replace('Bearer ', '') 130 | try { 131 | const decodedToken = JWT.verify(token, jwtKey) as Token 132 | if (!decodedToken[CONFIG_JWT.CLAIMS_NAMESPACE]) throw new Error('Claims namespace not found.') 133 | return decodedToken[CONFIG_JWT.CLAIMS_NAMESPACE] 134 | } catch (err) { 135 | throw new Error('Invalid or expired JWT token.') 136 | } 137 | } 138 | 139 | /** 140 | * Create JWT token. 141 | */ 142 | export const createHasuraJwt = (accountData: AccountData): string => 143 | sign( 144 | { 145 | [CONFIG_JWT.CLAIMS_NAMESPACE]: generatePermissionVariables(accountData, true) 146 | }, 147 | accountData 148 | ) 149 | -------------------------------------------------------------------------------- /src/shared/metadata.ts: -------------------------------------------------------------------------------- 1 | import { APPLICATION } from './config/index' 2 | import axios from 'axios' 3 | 4 | /** 5 | * https://hasura.io/docs/latest/graphql/core/api-reference/schema-metadata-api/relationship.html 6 | * Here we are using the schema-metadata-api to track the relationships between auth tables 7 | **/ 8 | 9 | interface Table { 10 | name: string 11 | schema: string 12 | foreignKey?: string 13 | } 14 | 15 | interface Relationship { 16 | name: string 17 | source: Table 18 | destination?: Table 19 | isArray?: boolean 20 | } 21 | 22 | function trackTable(table: Table) { 23 | return axios.post( 24 | APPLICATION.HASURA_ENDPOINT.replace('/v1/graphql', '/v1/query'), 25 | { 26 | type: 'track_table', 27 | args: { 28 | table: { 29 | name: table.name, 30 | schema: table.schema 31 | } 32 | } 33 | }, 34 | { 35 | headers: { 36 | 'x-hasura-admin-secret': APPLICATION.HASURA_GRAPHQL_ADMIN_SECRET 37 | } 38 | } 39 | ) 40 | } 41 | 42 | function trackRelationship(relationship: Relationship) { 43 | const sourceTable = relationship.source 44 | const destinationTable = relationship.destination 45 | 46 | const rules = destinationTable 47 | ? relationship.isArray 48 | ? { 49 | foreign_key_constraint_on: { 50 | table: { name: destinationTable.name, schema: destinationTable.schema }, 51 | column: destinationTable.foreignKey 52 | } 53 | } 54 | : { 55 | manual_configuration: { 56 | column_mapping: { id: destinationTable.foreignKey }, 57 | remote_table: { 58 | name: destinationTable.name, 59 | schema: destinationTable.schema 60 | } 61 | } 62 | } 63 | : { foreign_key_constraint_on: sourceTable.foreignKey } 64 | 65 | return axios.post( 66 | APPLICATION.HASURA_ENDPOINT.replace('/v1/graphql', '/v1/query'), 67 | { 68 | type: relationship.isArray ? 'create_array_relationship' : 'create_object_relationship', 69 | args: { 70 | table: { 71 | name: sourceTable.name, 72 | schema: sourceTable.schema 73 | }, 74 | name: relationship.name, 75 | using: rules 76 | } 77 | }, 78 | { 79 | headers: { 80 | 'x-hasura-admin-secret': APPLICATION.HASURA_GRAPHQL_ADMIN_SECRET 81 | } 82 | } 83 | ) 84 | } 85 | 86 | export async function applyMetadata(): Promise { 87 | console.log('Applying metadata') 88 | 89 | await Promise.allSettled([ 90 | trackTable({ name: 'account_providers', schema: 'auth' }), 91 | trackTable({ name: 'account_roles', schema: 'auth' }), 92 | trackTable({ name: 'accounts', schema: 'auth' }), 93 | trackTable({ name: 'providers', schema: 'auth' }), 94 | trackTable({ name: 'refresh_tokens', schema: 'auth' }), 95 | trackTable({ name: 'roles', schema: 'auth' }), 96 | trackTable({ name: 'migrations', schema: 'auth' }), 97 | trackTable({ name: 'users', schema: 'public' }) 98 | ]) 99 | 100 | await Promise.allSettled([ 101 | trackRelationship({ 102 | source: { name: 'account_providers', schema: 'auth', foreignKey: 'account_id' }, 103 | name: 'account' 104 | }), 105 | trackRelationship({ 106 | source: { name: 'account_providers', schema: 'auth', foreignKey: 'auth_provider' }, 107 | name: 'provider' 108 | }), 109 | trackRelationship({ 110 | source: { name: 'account_roles', schema: 'auth', foreignKey: 'account_id' }, 111 | name: 'account' 112 | }), 113 | trackRelationship({ 114 | source: { name: 'account_roles', schema: 'auth', foreignKey: 'role' }, 115 | name: 'roleByRole' 116 | }), 117 | trackRelationship({ 118 | source: { name: 'accounts', schema: 'auth', foreignKey: 'default_role' }, 119 | name: 'role' 120 | }), 121 | trackRelationship({ 122 | source: { name: 'accounts', schema: 'auth', foreignKey: 'user_id' }, 123 | name: 'user' 124 | }), 125 | trackRelationship({ 126 | source: { name: 'refresh_tokens', schema: 'auth', foreignKey: 'account_id' }, 127 | name: 'account' 128 | }), 129 | trackRelationship({ 130 | source: { name: 'users', schema: 'public' }, 131 | destination: { name: 'accounts', schema: 'auth', foreignKey: 'user_id' }, 132 | name: 'account' 133 | }) 134 | ]) 135 | 136 | await Promise.allSettled([ 137 | trackRelationship({ 138 | source: { name: 'accounts', schema: 'auth' }, 139 | destination: { name: 'account_providers', schema: 'auth', foreignKey: 'account_id' }, 140 | name: 'account_providers', 141 | isArray: true 142 | }), 143 | trackRelationship({ 144 | source: { name: 'accounts', schema: 'auth' }, 145 | destination: { name: 'account_roles', schema: 'auth', foreignKey: 'account_id' }, 146 | name: 'account_roles', 147 | isArray: true 148 | }), 149 | trackRelationship({ 150 | source: { name: 'accounts', schema: 'auth' }, 151 | destination: { name: 'refresh_tokens', schema: 'auth', foreignKey: 'account_id' }, 152 | name: 'refresh_tokens', 153 | isArray: true 154 | }), 155 | trackRelationship({ 156 | source: { name: 'providers', schema: 'auth' }, 157 | destination: { name: 'account_providers', schema: 'auth', foreignKey: 'auth_provider' }, 158 | name: 'account_providers', 159 | isArray: true 160 | }), 161 | trackRelationship({ 162 | source: { name: 'roles', schema: 'auth' }, 163 | destination: { name: 'account_roles', schema: 'auth', foreignKey: 'role' }, 164 | name: 'account_roles', 165 | isArray: true 166 | }), 167 | trackRelationship({ 168 | source: { name: 'roles', schema: 'auth' }, 169 | destination: { name: 'accounts', schema: 'auth', foreignKey: 'default_role' }, 170 | name: 'accounts', 171 | isArray: true 172 | }) 173 | ]) 174 | 175 | console.log('Finished applying metadata') 176 | } 177 | -------------------------------------------------------------------------------- /src/shared/migrations.ts: -------------------------------------------------------------------------------- 1 | import { migrate } from 'postgres-migrations' 2 | import { Client } from 'pg' 3 | 4 | export async function applyMigrations(): Promise { 5 | console.log('Applying migrations') 6 | 7 | const dbConfig = { 8 | connectionString: process.env.DATABASE_URL 9 | } 10 | 11 | const client = new Client(dbConfig) 12 | try { 13 | await client.connect() 14 | await migrate({ client }, './migrations') 15 | } finally { 16 | await client.end() 17 | } 18 | console.log('Finished applying migrations') 19 | } 20 | -------------------------------------------------------------------------------- /src/shared/request.ts: -------------------------------------------------------------------------------- 1 | import { APPLICATION } from './config' 2 | 3 | import { ASTNode } from 'graphql' 4 | import { GraphQLClient } from 'graphql-request' 5 | import { Variables } from 'graphql-request/dist/src/types' 6 | import { print } from 'graphql/language/printer' 7 | 8 | const client = new GraphQLClient(APPLICATION.HASURA_ENDPOINT, { 9 | get headers() { 10 | return APPLICATION.HASURA_GRAPHQL_ADMIN_SECRET 11 | ? { 'x-hasura-admin-secret': APPLICATION.HASURA_GRAPHQL_ADMIN_SECRET } 12 | : undefined 13 | } 14 | }) 15 | 16 | /** 17 | * To take advantage of syntax highlighting and auto-formatting 18 | * for GraphQL template literal tags (`gql`) in `src/utils/queries.ts`, 19 | * you need to `print()` queries before passing them to `graphql-request`. 20 | 21 | * https://github.com/prisma-labs/graphql-request/issues/10 22 | */ 23 | export async function request( 24 | query: ASTNode, 25 | variables?: Variables 26 | ): Promise { 27 | return (await client.request(print(query), variables)) as T 28 | } 29 | -------------------------------------------------------------------------------- /src/shared/s3.ts: -------------------------------------------------------------------------------- 1 | import { STORAGE } from './config' 2 | 3 | import AWS from 'aws-sdk' 4 | 5 | const s3 = new AWS.S3({ 6 | accessKeyId: STORAGE.S3_ACCESS_KEY_ID, 7 | secretAccessKey: STORAGE.S3_SECRET_ACCESS_KEY, 8 | endpoint: STORAGE.S3_ENDPOINT, 9 | s3ForcePathStyle: true, 10 | signatureVersion: 'v4', 11 | sslEnabled: STORAGE.S3_SSL_ENABLED 12 | }) 13 | 14 | export { s3 } 15 | -------------------------------------------------------------------------------- /src/shared/types.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express' 2 | 3 | export type ClaimValueType = 4 | | string 5 | | string[] 6 | | number 7 | | number[] 8 | | RegExp 9 | | RegExp[] 10 | | boolean 11 | | boolean[] 12 | | null 13 | | undefined 14 | 15 | /** 16 | * Claims interface. 17 | */ 18 | export interface Claims { 19 | 'x-hasura-user-id': string 20 | 'x-hasura-default-role': string 21 | 'x-hasura-allowed-roles': string[] 22 | [key: string]: ClaimValueType 23 | } 24 | 25 | /** 26 | * PermissionVariables interface. 27 | */ 28 | export interface PermissionVariables { 29 | 'user-id': string 30 | 'default-role': string 31 | 'allowed-roles': string[] 32 | [key: string]: ClaimValueType 33 | } 34 | 35 | /** 36 | * Token interface. 37 | */ 38 | export type Token = { 39 | [key: string]: Claims 40 | } & { 41 | exp: bigint 42 | iat: bigint 43 | iss: string 44 | sub: string 45 | } 46 | 47 | export interface Session { 48 | jwt_token: string | null; 49 | jwt_expires_in: number | null; 50 | refresh_token?: string 51 | user: UserData; 52 | } 53 | 54 | export interface UserData { 55 | [key: string]: ClaimValueType 56 | id: string 57 | email?: string 58 | display_name: string 59 | avatar_url?: string 60 | } 61 | 62 | export interface AccountData { 63 | id: string 64 | user: UserData 65 | active: boolean 66 | default_role: string 67 | account_roles: { role: string }[] 68 | is_anonymous: boolean 69 | ticket?: string 70 | otp_secret?: string 71 | mfa_enabled: boolean 72 | password_hash: string 73 | email: string 74 | new_email?: string 75 | } 76 | 77 | export interface QueryAccountData { 78 | auth_accounts: AccountData[] 79 | } 80 | 81 | export interface UpdateAccountData { 82 | update_auth_accounts: { 83 | affected_rows: number 84 | returning: { 85 | id: string 86 | }[] 87 | } 88 | } 89 | 90 | export interface DeleteAccountData { 91 | delete_auth_accounts: { affected_rows: number } 92 | } 93 | interface AccountProvider { 94 | account: AccountData 95 | } 96 | 97 | export interface QueryAccountProviderData { 98 | auth_account_providers: AccountProvider[] 99 | } 100 | 101 | export interface InsertAccountData { 102 | insert_auth_accounts: { 103 | returning: AccountData[] 104 | } 105 | } 106 | 107 | export interface InsertAccountProviderToUser { 108 | insert_auth_account_providers_one: { 109 | account: AccountData 110 | } 111 | } 112 | 113 | export interface RefreshTokenMiddleware { 114 | value: string | null 115 | type: 'query' | 'cookie' | null 116 | } 117 | 118 | export interface RequestExtended extends Request { 119 | refresh_token?: RefreshTokenMiddleware 120 | permission_variables?: PermissionVariables 121 | } 122 | 123 | export interface SetNewEmailData { 124 | update_auth_accounts: { 125 | returning: { 126 | user: UserData 127 | }[] 128 | affected_rows: number 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/shared/validation.ts: -------------------------------------------------------------------------------- 1 | import { REGISTRATION } from './config' 2 | import Joi from '@hapi/joi' 3 | 4 | interface ExtendedStringSchema extends Joi.StringSchema { 5 | allowedDomains(): this 6 | } 7 | 8 | interface ExtendedJoi extends Joi.Root { 9 | string(): ExtendedStringSchema 10 | } 11 | 12 | const extendedJoi: ExtendedJoi = Joi.extend((joi) => ({ 13 | type: 'string', 14 | base: joi.string(), 15 | messages: { 16 | 'string.allowedDomains': '{{#label}} is not in an authorised domain' 17 | }, 18 | rules: { 19 | allowedDomains: { 20 | method(): unknown { 21 | return this.$_addRule({ name: 'allowedDomains' }) 22 | }, 23 | validate(value: string, helpers): unknown { 24 | if (REGISTRATION.ALLOWED_EMAIL_DOMAINS) { 25 | const lowerValue = value.toLowerCase() 26 | const allowedEmailDomains = REGISTRATION.ALLOWED_EMAIL_DOMAINS.split(',') 27 | 28 | if (allowedEmailDomains.every((domain) => !lowerValue.endsWith(domain.toLowerCase()))) { 29 | return helpers.error('string.allowedDomains') 30 | } 31 | } 32 | 33 | return value 34 | } 35 | } 36 | } 37 | })) 38 | 39 | const passwordRule = Joi.string().min(REGISTRATION.MIN_PASSWORD_LENGTH).max(128) 40 | const passwordRuleRequired = passwordRule.required() 41 | 42 | const emailRule = extendedJoi.string().email().required().allowedDomains() 43 | 44 | const accountFields = { 45 | email: emailRule, 46 | password: passwordRuleRequired 47 | } 48 | 49 | const accountFieldsMagicLink = { 50 | email: emailRule, 51 | password: passwordRule 52 | } 53 | 54 | export const userDataFields = { 55 | user_data: Joi.object( 56 | REGISTRATION.CUSTOM_FIELDS.reduce<{ [k: string]: Joi.Schema[] }>( 57 | (aggr, key) => ({ 58 | ...aggr, 59 | [key]: [ 60 | Joi.string(), 61 | Joi.number(), 62 | Joi.boolean(), 63 | Joi.object(), 64 | Joi.array().items(Joi.string(), Joi.number(), Joi.boolean(), Joi.object()) 65 | ] 66 | }), 67 | {} 68 | ) 69 | ), 70 | register_options: Joi.object({ 71 | allowed_roles: Joi.array().items(Joi.string()), 72 | default_role: Joi.string() 73 | }) 74 | } 75 | 76 | export const registerSchema = Joi.object({ 77 | ...accountFields, 78 | ...userDataFields, 79 | cookie: Joi.boolean() 80 | }) 81 | 82 | export const getRegisterSchema = () => { 83 | return registerSchema 84 | } 85 | 86 | export const registerSchemaMagicLink = Joi.object({ 87 | ...accountFieldsMagicLink, 88 | ...userDataFields, 89 | cookie: Joi.boolean() 90 | }) 91 | 92 | export const getRegisterSchemaMagicLink = () => { 93 | return registerSchemaMagicLink 94 | } 95 | 96 | export const registerUserDataSchema = Joi.object(userDataFields) 97 | 98 | const ticketFields = { 99 | ticket: Joi.string().uuid({ version: 'uuidv4' }).required() 100 | } 101 | 102 | const codeFields = { 103 | code: Joi.string().length(6).required() 104 | } 105 | 106 | export const resetPasswordWithTicketSchema = Joi.object({ 107 | ...ticketFields, 108 | new_password: passwordRule 109 | }) 110 | 111 | export const changePasswordFromOldSchema = Joi.object({ 112 | old_password: passwordRule, 113 | new_password: passwordRule 114 | }) 115 | 116 | export const emailResetSchema = Joi.object({ 117 | new_email: emailRule 118 | }) 119 | 120 | export const logoutSchema = Joi.object({ 121 | all: Joi.boolean() 122 | }) 123 | 124 | export const mfaSchema = Joi.object(codeFields) 125 | export const loginAnonymouslySchema = Joi.object({ 126 | anonymous: Joi.boolean(), 127 | email: Joi.string(), // these will be checked more rigorously in `loginSchema` 128 | password: Joi.string() // these will be checked more rigorously in `loginSchema` 129 | }) 130 | export const magicLinkLoginAnonymouslySchema = Joi.object({ 131 | anonymous: Joi.boolean(), 132 | email: Joi.string() // these will be checked more rigorously in `loginSchema` 133 | }) 134 | export const loginSchema = extendedJoi.object({ 135 | email: emailRule, 136 | password: Joi.string().required(), 137 | cookie: Joi.boolean() 138 | }) 139 | export const loginSchemaMagicLink = extendedJoi.object({ 140 | email: emailRule, 141 | password: Joi.string(), 142 | cookie: Joi.boolean() 143 | }) 144 | export const forgotSchema = Joi.object({ email: emailRule }) 145 | export const verifySchema = Joi.object({ ...ticketFields }) 146 | export const totpSchema = Joi.object({ 147 | ...codeFields, 148 | ...ticketFields, 149 | cookie: Joi.boolean() 150 | }) 151 | 152 | export const imgTransformParams = Joi.object({ 153 | w: Joi.number().integer().min(0).max(8192), 154 | h: Joi.number().integer().min(0).max(8192), 155 | q: Joi.number().integer().min(0).max(100).default(100), 156 | b: Joi.number().integer().min(0.3).max(1000), 157 | r: Joi.alternatives().try(Joi.number(), Joi.string().valid('full')), 158 | token: Joi.string().uuid() 159 | }) 160 | 161 | export const fileMetadataUpdate = Joi.object({ 162 | // action: Joi.string().valid('revoke-token','some-other-action').required(), 163 | action: Joi.string().valid('revoke-token').required() 164 | }) 165 | 166 | export const magicLinkQuery = Joi.object({ 167 | token: Joi.string().required(), 168 | action: Joi.string().valid('log-in', 'register').required(), 169 | cookie: Joi.boolean().optional() 170 | }) 171 | -------------------------------------------------------------------------------- /src/start.ts: -------------------------------------------------------------------------------- 1 | require('module-alias/register') 2 | import './ts-start' 3 | -------------------------------------------------------------------------------- /src/test/global-setup.ts: -------------------------------------------------------------------------------- 1 | require('tsconfig-paths/register') 2 | import { applyMigrations } from '../shared/migrations' 3 | import { applyMetadata } from '../shared/metadata' 4 | import { Client } from 'pg' 5 | 6 | export default async (): Promise => { 7 | await applyMigrations() 8 | 9 | const client = new Client({ 10 | connectionString: process.env.DATABASE_URL 11 | }) 12 | try { 13 | await client.connect() 14 | await client.query(`ALTER TABLE "public"."users" ADD COLUMN IF NOT EXISTS "name" text NULL; 15 | INSERT INTO auth.roles (role) VALUES ('editor'), ('super-admin') ON CONFLICT DO NOTHING;;`) 16 | } finally { 17 | await client.end() 18 | } 19 | await applyMetadata() 20 | } 21 | -------------------------------------------------------------------------------- /src/test/server.ts: -------------------------------------------------------------------------------- 1 | import { APPLICATION } from '@shared/config' 2 | import { app } from '../server' 3 | import { SuperTest, Test, agent } from 'supertest' 4 | import { Server } from 'http' 5 | import getPort from 'get-port' 6 | 7 | 8 | export let request: SuperTest 9 | 10 | export let server: Server 11 | 12 | const start = async () => { 13 | server = app.listen(await getPort(), APPLICATION.HOST) 14 | request = agent(server) 15 | } 16 | 17 | const close = async () => { 18 | server.close() 19 | } 20 | 21 | beforeAll(async () => { 22 | await start() 23 | request = agent(server) 24 | }) 25 | 26 | // * Code that is executed after any jest test file that imports this file 27 | afterAll(async () => { 28 | await close() 29 | }) 30 | -------------------------------------------------------------------------------- /src/test/setup.ts: -------------------------------------------------------------------------------- 1 | jest.spyOn(global.console, 'error').mockImplementation(() => jest.fn()); 2 | -------------------------------------------------------------------------------- /src/test/supertest-shared-utils.ts: -------------------------------------------------------------------------------- 1 | import { Response } from 'superagent' 2 | 3 | export function end(done: any) { 4 | return (err: Response) => { 5 | if (err) return done(err); 6 | else return done(); 7 | } 8 | } 9 | 10 | export function validJwt() { 11 | return (res: Response) => { 12 | expect(res.body.jwt_token).toBeString() 13 | expect(res.body.jwt_expires_in).toBeNumber() 14 | } 15 | } 16 | 17 | export function saveJwt(fn: (jwtToken: string) => any) { 18 | return (res: Response) => { 19 | fn(res.body.jwt_token) 20 | } 21 | } 22 | 23 | export function saveRefreshToken(fn: (refreshToken: string) => any) { 24 | return (res: Response) => { 25 | fn(res.body.refresh_token) 26 | } 27 | } 28 | 29 | export function validRefreshToken(regex = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/) { 30 | return (res: Response) => { 31 | expect(res.body.refresh_token).toMatch(regex) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/test/utils.ts: -------------------------------------------------------------------------------- 1 | import fetch, { Response } from 'node-fetch' 2 | import { SuperTest, Test } from 'supertest' 3 | 4 | import { APPLICATION } from '@shared/config' 5 | 6 | export interface AccountLoginData { 7 | email: string 8 | password: string 9 | } 10 | 11 | export type AccountData = AccountLoginData & { 12 | token: string 13 | } 14 | 15 | export const generateRandomString = (): string => Math.random().toString(36).replace('0.', '') 16 | 17 | export const generateRandomEmail = () => `${generateRandomString()}@${generateRandomString()}.com` 18 | 19 | export async function withEnv( 20 | env: Record, 21 | agent: SuperTest, 22 | cb?: () => Promise, 23 | rollbackEnv?: Record 24 | ) { 25 | await agent.post('/change-env').send(env) 26 | if (cb) await cb() 27 | if (rollbackEnv) { 28 | await agent.post('/change-env').send(rollbackEnv) 29 | } 30 | } 31 | 32 | export const createAccountLoginData = (): AccountLoginData => ({ 33 | email: `${generateRandomString()}@${generateRandomString()}.com`, 34 | password: generateRandomString() 35 | }) 36 | 37 | export const registerAccount = async ( 38 | agent: SuperTest, 39 | user_data: Record = {} 40 | ): Promise => { 41 | const accountLoginData = createAccountLoginData() 42 | 43 | await withEnv( 44 | { 45 | AUTO_ACTIVATE_NEW_USERS: 'true' 46 | }, 47 | agent, 48 | async () => { 49 | await agent.post('/auth/register').send({ 50 | ...accountLoginData, 51 | user_data 52 | }) 53 | } 54 | ) 55 | 56 | return accountLoginData 57 | } 58 | 59 | export const loginAccount = async (agent: SuperTest, accountLoginData: AccountLoginData) => { 60 | // * Set the use variable so it is accessible to the jest test file 61 | const loginResponse = await agent.post('/auth/login').send(accountLoginData) 62 | const token = loginResponse.body.jwt_token as string 63 | return { 64 | ...accountLoginData, 65 | token 66 | } 67 | } 68 | 69 | export const registerAndLoginAccount = async (agent: SuperTest) => { 70 | return await loginAccount(agent, await registerAccount(agent)) 71 | } 72 | 73 | interface MailhogEmailAddress { 74 | Relays: string | null 75 | Mailbox: string 76 | Domain: string 77 | Params: string 78 | } 79 | 80 | interface MailhogMessage { 81 | ID: string 82 | From: MailhogEmailAddress 83 | To: MailhogEmailAddress[] 84 | Content: { 85 | Headers: { 86 | 'Content-Type': string[] 87 | Date: string[] 88 | From: string[] 89 | 'MIME-Version': string[] 90 | 'Message-ID': string[] 91 | Received: string[] 92 | 'Return-Path': string[] 93 | Subject: string[] 94 | To: string[] 95 | [key: string]: string[] 96 | } 97 | Body: string 98 | Size: number 99 | } 100 | Created: string 101 | Raw: { 102 | From: string 103 | To: string[] 104 | Data: string 105 | } 106 | } 107 | 108 | export interface MailhogSearchResult { 109 | total: number 110 | count: number 111 | start: number 112 | items: MailhogMessage[] 113 | } 114 | 115 | export const mailHogSearch = async (query: string, fields = 'to'): Promise => { 116 | const response = await fetch( 117 | `http://${APPLICATION.SMTP_HOST}:8025/api/v2/search?kind=${fields}&query=${query}` 118 | ) 119 | return ((await response.json()) as MailhogSearchResult).items 120 | } 121 | 122 | export const deleteMailHogEmail = async ({ ID }: MailhogMessage): Promise => 123 | await fetch(`http://${APPLICATION.SMTP_HOST}:8025/api/v1/messages/${ID}`, { method: 'DELETE' }) 124 | 125 | export const deleteEmailsOfAccount = async (email: string): Promise => 126 | (await mailHogSearch(email)).forEach(async (message) => await deleteMailHogEmail(message)) 127 | 128 | export const getHeaderFromLatestEmailAndDelete = async (email: string, header: string) => { 129 | const [message] = await mailHogSearch(email) 130 | 131 | if (!message) return 132 | 133 | const headerValue = message.Content.Headers[header][0] 134 | await deleteMailHogEmail(message) 135 | 136 | return headerValue 137 | } 138 | 139 | export const deleteAccount = async ( 140 | agent: SuperTest, 141 | account: AccountLoginData 142 | ): Promise => { 143 | // * Delete the account 144 | await agent.post('/auth/delete') 145 | // * Remove any message sent to this account 146 | await deleteEmailsOfAccount(account.email) 147 | } 148 | -------------------------------------------------------------------------------- /src/ts-start.ts: -------------------------------------------------------------------------------- 1 | import { APPLICATION } from '@shared/config' 2 | import axios from 'axios' 3 | 4 | import { app } from './server' 5 | import { applyMigrations } from './shared/migrations' 6 | import { applyMetadata } from './shared/metadata' 7 | 8 | function delay(ms: number) { 9 | return new Promise((resolve) => setTimeout(resolve, ms)) 10 | } 11 | 12 | const isHasuraReady = async () => { 13 | try { 14 | await axios.get(`${APPLICATION.HASURA_ENDPOINT.replace('/v1/graphql', '/healthz')}`) 15 | } catch (err) { 16 | console.log(`Couldn't find an hasura instance running on ${APPLICATION.HASURA_ENDPOINT}`) 17 | console.log('wait 10 seconds') 18 | await delay(10000) 19 | console.log('exit 1') 20 | process.exit(1) 21 | console.log('exit 1 completed') 22 | } 23 | } 24 | 25 | const start = async (): Promise => { 26 | await isHasuraReady() 27 | await applyMigrations() 28 | await applyMetadata() 29 | 30 | app.listen(APPLICATION.PORT, APPLICATION.HOST, () => { 31 | if (APPLICATION.HOST) { 32 | console.log(`Running on http://${APPLICATION.HOST}:${APPLICATION.PORT}`) 33 | } else { 34 | console.log(`Running on port ${APPLICATION.PORT}`) 35 | } 36 | }) 37 | } 38 | 39 | start() 40 | -------------------------------------------------------------------------------- /src/types/@nicokaiser/passport-apple.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/class-name-casing */ 2 | /* eslint-disable @typescript-eslint/no-explicit-any */ 3 | // ! This module declaration is incomplete and is only meant to work with HBP ! 4 | declare module '@nicokaiser/passport-apple' { 5 | export * from 'passport-generic-oauth' 6 | } 7 | -------------------------------------------------------------------------------- /src/types/notevil.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | // TODO crappy: default function is not exported correctly. For instance we cannot `import notevil from 'notevil'` then use `notevil.Function`' 3 | declare module 'notevil' { 4 | export function Function(...params: string[]): Function 5 | export function FunctionFactory(parentContext: { [key: string]: any }): Function 6 | export default function (code: string, context?: { [key: string]: any }): any 7 | } 8 | -------------------------------------------------------------------------------- /src/types/passport-generic-oauth.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/class-name-casing */ 2 | /* eslint-disable @typescript-eslint/no-explicit-any */ 3 | // ! This module declaration is incomplete and is only meant to work with HBP ! 4 | declare module 'passport-generic-oauth' { 5 | import passport = require('passport') 6 | import oauth2 = require('passport-oauth2') 7 | import express = require('express') 8 | type Omit = Pick> 9 | type Merge = Omit> & N 10 | 11 | export type Profile = Merge< 12 | passport.Profile, 13 | { 14 | email: string 15 | name?: { 16 | firstName: string 17 | lastName: string 18 | } 19 | } 20 | > 21 | 22 | export interface StrategyOption extends passport.AuthenticateOptions { 23 | clientID: string 24 | clientSecret: string 25 | callbackURL?: string 26 | passReqToCallbacks: boolean 27 | 28 | scope?: string[] 29 | // ? Probably incomplete 30 | } 31 | 32 | export type OAuth2StrategyOptionsWithoutRequiredURLs = Pick< 33 | oauth2._StrategyOptionsBase, 34 | Exclude 35 | > 36 | 37 | export interface _StrategyOptionsBase extends OAuth2StrategyOptionsWithoutRequiredURLs { 38 | clientID: string 39 | clientSecret: string 40 | callbackURL?: string 41 | passReqToCallbacks: boolean 42 | 43 | scope?: string[] 44 | // ? Probably incomplete 45 | } 46 | 47 | export interface StrategyOptions extends _StrategyOptionsBase { 48 | passReqToCallback?: false 49 | } 50 | export interface StrategyOptionsWithRequest extends _StrategyOptionsBase { 51 | passReqToCallback: true 52 | } 53 | 54 | export class Strategy extends oauth2.Strategy { 55 | constructor(options: StrategyOptions, verify: oauth2.VerifyFunction) 56 | constructor(options: StrategyOptionsWithRequest, verify: oauth2.VerifyFunctionWithRequest) 57 | userProfile(accessToken: string, done: (err?: Error | null, profile?: any) => void): void 58 | 59 | name: string 60 | authenticate(req: express.Request, options?: passport.AuthenticateOptions): void 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/types/passport-windowslive.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/class-name-casing */ 2 | /* eslint-disable @typescript-eslint/no-explicit-any */ 3 | // ! This module declaration is incomplete and is only meant to work with HBP ! 4 | declare module 'passport-windowslive' { 5 | export * from 'passport-generic-oauth' 6 | } 7 | -------------------------------------------------------------------------------- /test-mocks/example.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nhost/hasura-backend-plus/2713d00ddf8480d2ff66cb83f22af8170b142edd/test-mocks/example.jpg -------------------------------------------------------------------------------- /test-mocks/migrations/1585679214182_custom_user_column/down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "public"."users" DROP COLUMN "name"; -------------------------------------------------------------------------------- /test-mocks/migrations/1585679214182_custom_user_column/up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE 2 | "public"."users" 3 | ADD 4 | COLUMN "name" text NULL; 5 | 6 | INSERT INTO 7 | auth.roles (role) 8 | VALUES 9 | ('editor'), 10 | ('super-admin'); 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": [ 3 | "node_modules" 4 | ], 5 | "include": [ 6 | "src/**/*.ts" 7 | ], 8 | "compilerOptions": { 9 | "lib": [ 10 | "dom", 11 | "es2020" 12 | ], 13 | "paths": { 14 | "*": [ 15 | "src/types/*" 16 | ], 17 | "@shared/*": [ 18 | "src/shared/*" 19 | ], 20 | "@test/*": [ 21 | "src/test/*" 22 | ] 23 | }, 24 | "module": "commonjs", 25 | "outDir": "dist", 26 | "strict": true, 27 | "target": "es2019", 28 | "baseUrl": ".", 29 | "noUnusedLocals": true, 30 | "esModuleInterop": true, 31 | "moduleResolution": "node", 32 | "strictNullChecks": true, 33 | "resolveJsonModule": false, 34 | "inlineSourceMap": true, 35 | } 36 | } --------------------------------------------------------------------------------