Repository: OultimoCoder/cloudflare-planetscale-hono-boilerplate Branch: main Commit: 3ceabdc9c29a Files: 99 Total size: 344.8 KB Directory structure: gitextract_e9sl75y4/ ├── .dev.vars.example ├── .env.example ├── .env.test.example ├── .eslintignore ├── .gitignore ├── .husky/ │ ├── pre-commit │ └── pre-push ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── TODO.md ├── bin/ │ └── createApp.js ├── bindings.d.ts ├── build.js ├── eslint.config.js ├── jest.config.js ├── migrations/ │ └── 01_initial.ts ├── package.json ├── scripts/ │ └── migrate.ts ├── src/ │ ├── config/ │ │ ├── authProviders.ts │ │ ├── config.ts │ │ ├── database.ts │ │ ├── roles.ts │ │ └── tokens.ts │ ├── controllers/ │ │ ├── auth/ │ │ │ ├── auth.controller.ts │ │ │ └── oauth/ │ │ │ ├── apple.controller.ts │ │ │ ├── discord.controller.ts │ │ │ ├── facebook.controller.ts │ │ │ ├── github.controller.ts │ │ │ ├── google.controller.ts │ │ │ ├── oauth.controller.ts │ │ │ └── spotify.controller.ts │ │ └── user.controller.ts │ ├── durable-objects/ │ │ └── rate-limiter.do.ts │ ├── factories/ │ │ └── oauth.factory.ts │ ├── index.ts │ ├── middlewares/ │ │ ├── auth.ts │ │ ├── error.ts │ │ └── rate-limiter.ts │ ├── models/ │ │ ├── base.model.ts │ │ ├── oauth/ │ │ │ ├── apple-user.model.ts │ │ │ ├── discord-user.model.ts │ │ │ ├── facebook-user.model.ts │ │ │ ├── github-user.model.ts │ │ │ ├── google-user.model.ts │ │ │ ├── oauth-base.model.ts │ │ │ └── spotify-user.model.ts │ │ ├── one-time-oauth-code.ts │ │ ├── token.model.ts │ │ └── user.model.ts │ ├── routes/ │ │ ├── auth.route.ts │ │ ├── index.ts │ │ └── user.route.ts │ ├── services/ │ │ ├── auth.service.ts │ │ ├── email.service.ts │ │ ├── oauth/ │ │ │ ├── apple.service.ts │ │ │ ├── facebook.service.ts │ │ │ ├── github.service.ts │ │ │ └── spotify.service.ts │ │ ├── token.service.ts │ │ └── user.service.ts │ ├── tables/ │ │ ├── oauth.table.ts │ │ ├── one-time-oauth-code.table.ts │ │ └── user.table.ts │ ├── types/ │ │ └── oauth.types.ts │ ├── utils/ │ │ ├── api-error.ts │ │ ├── utils.ts │ │ └── zod.ts │ └── validations/ │ ├── auth.validation.ts │ ├── custom.refine.validation.ts │ ├── custom.transform.validation.ts │ ├── custom.type.validation.ts │ └── user.validation.ts ├── tests/ │ ├── cloudflare-test.d.ts │ ├── fixtures/ │ │ ├── authorisations.fixture.ts │ │ ├── token.fixture.ts │ │ └── user.fixture.ts │ ├── integration/ │ │ ├── auth/ │ │ │ ├── auth.test.ts │ │ │ └── oauth/ │ │ │ ├── apple.test.ts │ │ │ ├── discord.test.ts │ │ │ ├── facebook.test.ts │ │ │ ├── github.test.ts │ │ │ ├── google.test.ts │ │ │ └── spotify.test.ts │ │ ├── index.test.ts │ │ ├── rate-limiter.test.ts │ │ └── user.test.ts │ ├── mocks/ │ │ └── awsClientStub/ │ │ ├── aws-client-stub.ts │ │ ├── expect-mock.ts │ │ ├── index.ts │ │ └── mock-client.ts │ ├── tsconfig.json │ ├── utils/ │ │ ├── clear-db-tables.ts │ │ └── test-request.ts │ └── vitest.d.ts ├── tsconfig.json ├── vitest.config.ts └── wrangler.toml.example ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dev.vars.example ================================================ # This file is used to set secrets for running a local dev server via npm run dev as well as tests # Use a valid fake PKCS8 key for apple oauth JWT_SECRET = "iamarandomjwtsecret" DATABASE_PASSWORD = "database password" AWS_ACCESS_KEY_ID = "realorfake" AWS_SECRET_ACCESS_KEY = "realorfake" SENTRY_DSN = "realorempty" OAUTH_GITHUB_CLIENT_SECRET = "realorfake" OAUTH_DISCORD_CLIENT_SECRET = "realorfake" OAUTH_SPOTIFY_CLIENT_SECRET = "realorfake" OAUTH_GOOGLE_CLIENT_SECRET = "realorfake" OAUTH_FACEBOOK_CLIENT_SECRET = "realorfake" OAUTH_APPLE_PRIVATE_KEY = "-----BEGIN PRIVATE KEY-----MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCFUxrQFuI9oTnQb8SeYBGJkiVeB85Xare9anUaKhRTyuP83n/UxBTFpEc/DTFwy3o/7OKvqLhhBhbrinsfjV7ZXMJJzPEDeMcgdFfwaooGapEJMEb9QkjfXAXidffyDgxeFXvs0nd1IWWTVKnou6859FyNGabhRN8eA+syOgZk4RfAPwiXZl5q+jDdHKrH/fVKhgkihlGbZFHevEHvHLE1ZxLTcMWxQjOUIyS86o+N6hHWTNJsSmo7apYDkEjQQNosegq50xTmml6NcjwtV2v9tVzaCeGAOl9IyJfhHRBYPt16v/DqpbWCd4S9REut79GJR1lW5ZZm0stpDD7myM3BAgMBAAECggEAAR7TaxvCH3f3IyoJSjZu90u/3iQtJv1p2WDnZoajgJfEJjgddWWHcijBA4XiHDtNhfOA7S57DC+vqh+SDNAUk7mMlW+wN9IARGTN52KR0d975AqgkhjIQX5Fu2M35/QXxQOjtLgJEnYrIxuTSPYo0REdZP8p8JsyT8+DHrsvmhHqFQHszNEjnZ33/JAFelOUJgbeisHRv99Voiv2qQi4al//iZ00NKrRvkRA417Mim1cwhkSnbzUXdhLAH4MInGzizmQVpHlzSYVV1vzXqYXH0K8arMY60fCqf9zxKwYCbfEJjnAREof9vs19V9wnHtbI9rcODEfRz5I/61oLrGl0QKBgQC5wuQiUjFX3GKUXuVOvvMZIb8Zk5T8QPhamZDHHlx3v3vZ8srlQ8tSSqaMfgSYuSzYDi4LAOI1nZtBHp33D4XZZQKLql3cA9i0xrJtHim6ep2w+7if0mr57v+sgG16eS3AHiTknYilzO56MSzaOewlgHevcw4IhoNlFEyDAECQKQKBgQC3vIJh6phpj5lOY3xAGb/rVUZ/PtNZco2ppGm6Q7u3RDkey55oNxGXOXOw8Jrv3qlieNSoId/ZMrX0Mfe41K2HYQo78qVGWA+kOagc3tsP0uAK9EYLs034HTAaJof6XDHUgUGwmLJ/zDrc9Lk1S5cUZ6Js26o/gBASVQZzsLEj2QKBgQCm6BLdN6a4P/+fOoiksXNx4F15WJ5j7Oh5V0O7dW819SoOEVX2m2xje0mcMFpm8vL1CgCayGd4Ly1hXGYop5znURfxb9k3p4keHO4Slyh9MlDfxb0EdSbDfNfjId28Tocp+KvDcjxmZPTde7PGPIcOxxhC34j7ZglHV+7LQf3AyQKBgBPsCq8XQsNfYJ4RR22j3R1lN6mgZEY0l4unWhdqNLZgXVkrdteR8QRWpGaxD/umRvN4aoZ4dc8VIomByXxvAwnEydlKLAV+kuOZpNLMjzAeC1Dkv5uRK4kVkRukxeWtjXGfOkItrF0TBebjWhmfQphhzEjFYKZV+mgic/qjU/GxAoGBALQlMAXKKPrt/nrAcTTjQhPNhTKKRhkcICdTVZhbKkHRbwBiMVsPUvDualQpkflzYEdf+mBxz6g9Gr4S8scY/1CljlH+SHK1QA7seGXlQ3+saY2PLCDtV8cuZO4ggV0tbTmR8RQCMNO0HUjfSD4vkwmnu8nYbI8TalKFG3t8NrV2-----END PRIVATE KEY-----" ================================================ FILE: .env.example ================================================ # Please note that this file is only used for running migrations for a development database # The rest of the variables for the development environment are setup in the wrangler.toml file DATABASE_NAME='name' DATABASE_USERNAME='username' DATABASE_HOST='host' DATABASE_PASSWORD='password' ================================================ FILE: .env.test.example ================================================ # Please note that aws credentials and oauth credentials don't have to work only planetscale # The apple oauth private key must be in the pkcs8 format # credentials are required to run the tests. ENV = 'test' JWT_SECRET='iamasecret' JWT_ACCESS_EXPIRATION_MINUTES=30 JWT_REFRESH_EXPIRATION_DAYS=30 JWT_RESET_PASSWORD_EXPIRATION_MINUTES=15 JWT_VERIFY_EMAIL_EXPIRATION_MINUTES=15 DATABASE_NAME='name' DATABASE_USERNAME='username' DATABASE_HOST='host' DATABASE_PASSWORD='password' AWS_ACCESS_KEY_ID='test' AWS_SECRET_ACCESS_KEY='test' AWS_REGION='eu-west-1' EMAIL_SENDER='noreply@dictionaryapi.io' SENTRY_DSN='' OAUTH_WEB_REDIRECT_URL='https://frontend.com/login' OAUTH_IOS_REDIRECT_URL='app://login' OAUTH_ANDROID_REDIRECT_URL='app://login' OAUTH_GITHUB_CLIENT_ID='myclientid' OAUTH_GITHUB_CLIENT_SECRET='myclientsecret' OAUTH_DISCORD_CLIENT_ID='myclientid' OAUTH_DISCORD_CLIENT_SECRET='myclientsecret' OAUTH_SPOTIFY_CLIENT_ID='myclientid' OAUTH_SPOTIFY_CLIENT_SECRET='myclientsecret' OAUTH_GOOGLE_CLIENT_ID='myclientid' OAUTH_GOOGLE_CLIENT_SECRET='myclientsecret' OAUTH_GOOGLE_REDIRECT_URL='https://frontend.com/login' OAUTH_FACEBOOK_CLIENT_ID='myclientid' OAUTH_FACEBOOK_CLIENT_SECRET='myclientsecret' OAUTH_APPLE_CLIENT_ID='myclientid' OAUTH_APPLE_PRIVATE_KEY='-----BEGIN PRIVATE KEY-----\n\n-----END PRIVATE KEY-----' OAUTH_APPLE_KEY_ID='mykeyid' OAUTH_APPLE_TEAM_ID='myteamid' OAUTH_APPLE_JWT_ACCESS_EXPIRATION_MINUTES=30 OAUTH_APPLE_REDIRECT_URL='https://frontend.com/login' ================================================ FILE: .eslintignore ================================================ node_modules dist ================================================ FILE: .gitignore ================================================ /target /dist **/*.rs.bk pkg/ wasm-pack.log worker/ node_modules/ .vscode .env* !*.example .mf wrangler.toml coverage .vscode pnpm-lock.yaml .dev.vars ================================================ FILE: .husky/pre-commit ================================================ npm run lint ================================================ FILE: .husky/pre-push ================================================ npm run tests:coverage ================================================ FILE: .prettierignore ================================================ node_modules dist ================================================ FILE: .prettierrc ================================================ { "singleQuote": true, "semi": false, "trailingComma": "none", "tabWidth": 2, "printWidth": 100, "bracketSpacing": true } ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2022 Ben Louis Armstrong Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # RESTful API Cloudflare Workers Boilerplate A boilerplate/starter project for quickly building RESTful APIs using [Cloudflare Workers](https://workers.cloudflare.com/), [Hono](https://honojs.dev/), and [PlanetScale](https://planetscale.com/). Inspired by [node-express-boilerplate](https://github.com/hagopj13/node-express-boilerplate) by hagopj13. ## Quick Start To create a project, simply run: ```bash npx create-cf-planetscale-app ``` Or ```bash npm init cf-planetscale-app ``` ## Table of Contents - [RESTful API Cloudflare Workers Boilerplate](#restful-api-cloudflare-workers-boilerplate) - [Quick Start](#quick-start) - [Table of Contents](#table-of-contents) - [Features](#features) - [Commands](#commands) - [Error Handling](#error-handling) - [Validation](#validation) - [Authentication](#authentication) - [Emails](#emails) - [Authorisation](#authorisation) - [Rate Limiting](#rate-limiting) - [Contributing](#contributing) - [Inspirations](#inspirations) - [License](#license) ## Features - **SQL database**: [PlanetScale](https://planetscale.com/) using [Kysely](https://github.com/koskimas/kysely) as a type-safe SQl query builder - **Authentication and authorization**: using JWT - **Validation**: request data validation using [Zod](https://github.com/colinhacks/zod) - **Logging**: using [Sentry](https://sentry.io/) - **Testing**: unit and integration tests using [Vitest](https://vitest.dev/) - **Error handling**: centralised error handling mechanism provided by [Hono](https://honojs.dev/) - **Git hooks**: with [Husky](https://github.com/typicode/husky) - **Linting**: with [ESLint](https://eslint.org) and [Prettier](https://prettier.io) - **Emails**: with [Amazon SES](https://aws.amazon.com/ses/) - **Oauth**: Support for Discord, Github, Spotify, Google, Apple and Facebook. Support coming for Instagram and Twitter - **Rate Limiting**: using Cloudflare durable objects you can rate limit endpoints usin the sliding window algorithm ## Commands Running locally: ```bash npm run dev ``` Testing: ```bash # run all tests npm run tests # run test coverage npm run tests:coverage ``` Linting: ```bash # run ESLint npm run lint # fix ESLint errors npm run lint:fix # run prettier npm run prettier # fix prettier errors npm run prettier:fix ``` Migrations: To deploy to production you must first deploy to a test/dev branch on Planetscale and then create a deploy request and merge the schema into production. ```bash # run all migrations for testing npm run migrate:test:latest # remove all migrations for testing npm run migrate:test:none # revert last migration for testing npm run migrate:test:down ``` Deploy to Cloudflare: ```bash npm run deploy npm run deploy ``` ## Error Handling The app has a centralized error handling mechanism provided by [Hono](https://honojs.dev/). ```javascript app.onError(errorHandler) ``` All errors will be caught by the errorHandler which converts the error to an ApiError and formats it in a JSON response. Any errors that aren't intentionally thrown, e.g. 500 errors, are logged to Sentry. The error handling middleware sends an error response, which has the following format: ```json { "code": 404, "message": "Not found" } ``` When running in development mode, the error response also contains the error stack. ## Validation Request data is validated using [Zod](https://github.com/colinhacks/zod). The validation schemas are defined in the `src/validations` directory and are used in the controllers by getting either the query or body and then calling the parse on the relevant validation function: ```javascript const getUsers: Handler<{ Bindings: Bindings }> = async (c) => { const config = getConfig(c.env) const queryParse = c.req.query() const query = userValidation.getUsers.parse(queryParse) const filter = { email: query.email } const options = { sortBy: query.sort_by, limit: query.limit, page: query.page } const result = await userService.queryUsers(filter, options, config.database) return c.json(result, httpStatus.OK) } ``` ## Authentication To require authentication for certain routes, you can use the `auth` middleware. ```javascript import { Hono } from 'hono' import * as userController from '../controllers/user.controller' import { auth } from '../middlewares/auth' const route = new Hono<{ Bindings: Bindings }>() route.post('/', auth(), userController.createUser) export { route } ``` These routes require a valid JWT access token in the Authorization request header using the Bearer schema. If the request does not contain a valid access token, an Unauthorized (401) error is thrown. ## Emails Support for Email sending using [Amazon SES](https://aws.amazon.com/ses/). Just call the `sendEmail` function in `src/services/email.service.ts`: ```javascript const sendResetPasswordEmail = async (email: string, emailData: EmailData, config: Config) => { const message = { Subject: { Data: 'Reset your password', Charset: 'UTF-8' }, Body: { Text: { Charset: 'UTF-8', Data: ` Hello ${emailData.name} Please reset your password by clicking the following link: ${emailData.token} ` } } } await sendEmail(email, config.email.sender, message, config.aws) } ``` ## Authorisation The `auth` middleware can also be used to require certain rights/permissions to access a route. ```javascript import { Hono } from 'hono' import * as userController from '../controllers/user.controller' import { auth } from '../middlewares/auth' const route = new Hono<{ Bindings: Bindings }>() route.post('/', auth('manageUsers'), userController.createUser) export { route } ``` In the example above, an authenticated user can access this route only if that user has the `manageUsers` permission. The permissions are role-based. You can view the permissions/rights of each role in the `src/config/roles.ts` file. If the user making the request does not have the required permissions to access this route, a Forbidden (403) error is thrown. ## Rate Limiting To apply rate limits for certain routes, you can use the `rateLimit` middleware. ```javascript import { Hono } from 'hono' import { Environment } from '../../bindings' import { auth } from '../middlewares/auth' import { rateLimit } from '../middlewares/rateLimiter' export const route = new Hono() const twoMinutes = 120 const oneRequest = 1 route.post( '/send-verification-email', auth(), rateLimit(twoMinutes, oneRequest), authController.sendVerificationEmail ) ``` This uses Cloudflare durable objects to apply rate limits using the sliding window algorithm. You can specify the interval size in seconds and how many requests are allowed per interval. If the rate limit is hit a `429` will be returned to the client. These headers are returned with each endpoint that has rate limiting applied: * `X-RateLimit-Limit` - How many requests are allowed per window * `X-RateLimit-Reset` - How many seconds until the current window resets * `X-RateLimit-Policy` - Details about the rate limit policy in this format `${limit};w=${interval};comment="Sliding window"` * `X-RateLimit-Remaining` - How many requests you can send until you will be rate limited. Please note this doesn't just reset to the limit when the reset period hits. Use it as indicator of your current throughput e.g. if you have 12 requests allowed every 1 second and remaining is 0 you are at 100% throughput, but if it is 6 you are 50% throughput. This value constantly changes as the window progresses either increasing or decreasing based on your throughput The rate limit will be based on IP unless the user is authenticated then it will be based on the user ID. ## Contributing Contributions are more than welcome! ## Inspirations - [hagopj13/node-express-boilerplate](https://github.com/hagopj13/node-express-boilerplate) ## License [MIT](LICENSE) ================================================ FILE: TODO.md ================================================ ### Todo - [ ] Flesh out README - [ ] Oauth - [ ] Add support for Twitter - [ ] API docs - [ ] Fix all types - [ ] CI/CD using Github Actions - [ ] MFA - [ ] 100% test coverage - [ ] Unit tests ### In Progress ================================================ FILE: bin/createApp.js ================================================ #!/usr/bin/env node /* eslint no-console: "off" */ import { exec as child_exec } from 'child_process' import fs from 'fs' import path from 'path' import util from 'util' // Utility functions const exec = util.promisify(child_exec) const runCmd = async (command) => { try { const { stdout, stderr } = await exec(command) console.log(stdout) console.log(stderr) } catch(err) { console.log(err) } } // Validate arguments if (process.argv.length < 3) { console.log('Please specify the target project directory.') console.log('For example:') console.log(' npx create-cf-planetscale-app my-app') console.log(' OR') console.log(' npm init create-cf-planetscale-app my-app') process.exit(1) } // Define constants const ownPath = process.cwd() const folderName = process.argv[2] const appPath = path.join(ownPath, folderName) const repo = 'https://github.com/OultimoCoder/cloudflare-planetscale-hono-boilerplate' // Check if directory already exists try { fs.mkdirSync(appPath) } catch (err) { if (err.code === 'EEXIST') { console.log('Directory already exists. Please choose another name for the project.') } else { console.log(err) } process.exit(1) } const setup = async () => { try { // Clone repo console.log(`Downloading files from repo ${repo}`) await runCmd(`git clone --depth 1 ${repo} ${folderName}`) console.log('Cloned successfully.') console.log('') // Change directory process.chdir(appPath) // Install dependencies console.log('Installing dependencies...') await runCmd('npm install') console.log('Dependencies installed successfully.') console.log() // Copy wrangler.toml fs.copyFileSync( path.join(appPath, 'wrangler.toml.example'), path.join(appPath, 'wrangler.toml') ) console.log('wrangler.toml copied.') // Delete .git folder await runCmd('npx rimraf ./.git') // Remove extra files fs.unlinkSync(path.join(appPath, 'TODO.md')) fs.unlinkSync(path.join(appPath, 'bin', 'createApp.js')) fs.rmdirSync(path.join(appPath, 'bin')) console.log('Installation is now complete!') console.log() console.log('Enjoy your production-ready Cloudflare Workers project!') console.log('Check README.md for more info.') } catch (error) { console.log(error) } } setup() ================================================ FILE: bindings.d.ts ================================================ import type { JwtPayload } from '@tsndr/cloudflare-worker-jwt' import type { Toucan } from 'toucan-js' import type { RateLimiter } from './src/durable-objects/rate-limiter.do' type Environment = { Bindings: { ENV: string JWT_SECRET: string JWT_ACCESS_EXPIRATION_MINUTES: number JWT_REFRESH_EXPIRATION_DAYS: number JWT_RESET_PASSWORD_EXPIRATION_MINUTES: number JWT_VERIFY_EMAIL_EXPIRATION_MINUTES: number DATABASE_NAME: string DATABASE_USERNAME: string DATABASE_PASSWORD: string DATABASE_HOST: string RATE_LIMITER: DurableObjectNamespace SENTRY_DSN: string AWS_ACCESS_KEY_ID: string AWS_SECRET_ACCESS_KEY: string AWS_REGION: string EMAIL_SENDER: string OAUTH_WEB_REDIRECT_URL: string OAUTH_ANDROID_REDIRECT_URL: string OAUTH_IOS_REDIRECT_URL: string OAUTH_GITHUB_CLIENT_ID: string OAUTH_GITHUB_CLIENT_SECRET: string OAUTH_GOOGLE_CLIENT_ID: string OAUTH_GOOGLE_CLIENT_SECRET: string OAUTH_DISCORD_CLIENT_ID: string OAUTH_DISCORD_CLIENT_SECRET: string OAUTH_SPOTIFY_CLIENT_ID: string OAUTH_SPOTIFY_CLIENT_SECRET: string OAUTH_FACEBOOK_CLIENT_ID: string OAUTH_FACEBOOK_CLIENT_SECRET: string OAUTH_APPLE_CLIENT_ID: string OAUTH_APPLE_KEY_ID: string OAUTH_APPLE_TEAM_ID: string OAUTH_APPLE_JWT_ACCESS_EXPIRATION_MINUTES: number OAUTH_APPLE_PRIVATE_KEY: string } Variables: { payload: JwtPayload sentry: Toucan } } ================================================ FILE: build.js ================================================ import { build } from 'esbuild' try { await build({ entryPoints: ['./src/index.ts'], bundle: true, outdir: './dist/', sourcemap: true, minify: true, conditions: ['worker', 'browser'], outExtension: { '.js': '.mjs' }, format: 'esm', target: 'esnext' }) } catch { process.exitCode = 1 } ================================================ FILE: eslint.config.js ================================================ import eslint from '@eslint/js' import importx from 'eslint-plugin-import-x' import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended' import vitest from 'eslint-plugin-vitest' import globals from 'globals' import tseslint from 'typescript-eslint' const defaultFiles = [ 'src/**', 'tests/**', 'bindings.d.ts', 'scripts/**', 'migrations/**' ] const config = { languageOptions: { sourceType: 'module', ecmaVersion: 2021, globals: { ...globals.node, ...globals.browser, ...globals.serviceworker, fetch: 'readonly', Response: 'readonly', Request: 'readonly', addEventListener: 'readonly', ENV: 'readonly' }, }, plugins: { 'import-x': importx }, rules: { quotes: ['error', 'single'], 'no-console': 'error', 'sort-imports': 'off', 'import-x/order': [ 'error', { alphabetize: { order: 'asc' }, } ], 'node/no-missing-import': 'off', 'node/no-missing-require': 'off', 'node/no-deprecated-api': 'off', 'node/no-unpublished-import': 'off', 'node/no-unpublished-require': 'off', 'node/no-unsupported-features/es-syntax': 'off', semi: ['error', 'never'], 'no-debugger': ['error'], 'no-empty': ['warn', { allowEmptyCatch: true }], 'no-process-exit': 'off', 'no-useless-escape': 'off', 'max-len': ['error', { code: 100 }], '@typescript-eslint/no-unused-vars': [ 'error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_', }, ], }, files: defaultFiles } export const testConfig = { ...vitest.configs.recommended, plugins: { vitest: vitest }, files: ['tests/**'] } export default tseslint.config( { ignores: ['dist/', 'coverage/', 'node_modules/'], }, { files: defaultFiles, ...eslint.configs.recommended }, ...tseslint.configs.recommended, config, testConfig, { files: defaultFiles, ...eslintPluginPrettierRecommended } ) ================================================ FILE: jest.config.js ================================================ export default { preset: 'ts-jest/presets/default-esm', extensionsToTreatAsEsm: ['.ts'], clearMocks: true, globals: { 'ts-jest': { tsconfig: 'tests/tsconfig.json', useESM: true, isolatedModules: true, }, }, testEnvironment: 'miniflare', testEnvironmentOptions: { scriptPath: 'dist/index.mjs', modules: true }, transformIgnorePatterns: [ 'node_modules/(?!(@planetscale|kysely-planetscale|@aws-sdk|worker-auth-providers|uuid))' ], moduleNameMapper: {'^uuid$': 'uuid'}, collectCoverageFrom: ['src/**/*.{ts,js}'], coveragePathIgnorePatterns: [ 'src/durable-objects' // Jest doesn't accurately report coverage for Durable Objects ], testTimeout: 20000 } ================================================ FILE: migrations/01_initial.ts ================================================ import { Kysely, sql } from 'kysely' import { Database } from '../src/config/database' export async function up(db: Kysely) { await db.schema .createTable('user') .addColumn('id', 'varchar(21)', (col) => col.primaryKey()) .addColumn('name', 'varchar(255)') .addColumn('password', 'varchar(255)') .addColumn('email', 'varchar(255)', (col) => col.notNull().unique()) .addColumn('is_email_verified', 'boolean', (col) => col.defaultTo(false)) .addColumn('role', 'varchar(255)', (col) => col.defaultTo('user')) .addColumn('created_at', 'timestamp', (col) => col.defaultTo(sql`NOW()`)) .addColumn('updated_at', 'timestamp', (col) => { return col.defaultTo(sql`NOW()`).modifyEnd(sql`ON UPDATE NOW()`) }) .execute() await db.schema .createTable('authorisations') .addColumn('provider_type', 'varchar(255)', (col) => col.notNull()) .addColumn('provider_user_id', 'varchar(255)', (col) => col.notNull()) .addColumn('user_id', 'varchar(255)', (col) => col.notNull()) .addPrimaryKeyConstraint('primary_key', ['provider_type', 'provider_user_id', 'user_id']) .addUniqueConstraint('unique_provider_user', ['provider_type', 'provider_user_id']) .addColumn('created_at', 'timestamp', (col) => col.defaultTo(sql`NOW()`)) .addColumn('updated_at', 'timestamp', (col) => { return col.defaultTo(sql`NOW()`).modifyEnd(sql`ON UPDATE NOW()`) }) .execute() await db.schema.createIndex('user_email_index').on('user').column('email').execute() await db.schema .createIndex('authorisations_user_id_index') .on('authorisations') .column('user_id') .execute() await db.schema .createTable('one_time_oauth_code') .addColumn('code', 'varchar(255)', (col) => col.primaryKey()) .addColumn('user_id', 'varchar(255)', (col) => col.notNull()) .addColumn('access_token', 'varchar(255)', (col) => col.notNull()) .addColumn('access_token_expires_at', 'timestamp', (col) => col.notNull()) .addColumn('refresh_token', 'varchar(255)', (col) => col.notNull()) .addColumn('refresh_token_expires_at', 'timestamp', (col) => col.notNull()) .addColumn('expires_at', 'timestamp', (col) => col.notNull()) .addColumn('created_at', 'timestamp', (col) => col.defaultTo(sql`NOW()`)) .addColumn('updated_at', 'timestamp', (col) => { return col.defaultTo(sql`NOW()`).modifyEnd(sql`ON UPDATE NOW()`) }) .execute() } export async function down(db: Kysely) { await db.schema.dropTable('user').ifExists().execute() await db.schema.dropTable('authorisations').ifExists().execute() await db.schema.dropTable('one_time_oauth_code').ifExists().execute() } ================================================ FILE: package.json ================================================ { "name": "create-cf-planetscale-app", "version": "3.0.0", "description": "Create a Cloudflare workers app for building production ready RESTful APIs using Hono", "main": "dist/index.mjs", "engines": { "node": ">=12.0.0" }, "bin": "bin/createApp.js", "repository": "https://github.com/OultimoCoder/cloudflare-planetscale-hono-boilerplate.git", "author": "Ben Louis Armstrong ", "license": "MIT", "keywords": [ "cloudflare", "workers", "cloudflare-worker", "cloudflare-workers", "planetscale", "boilerplate", "template", "starter", "example", "vitest", "hono", "api", "rest", "sql", "oauth", "jwt", "es6", "es7", "es8", "es9", "jwt", "zod", "eslint", "prettier" ], "scripts": { "build": "node ./build.js", "dev": "wrangler dev dist/index.mjs --live-reload --port 8787", "tests": "npm run build && vitest run", "tests:coverage": "npm run build && vitest run --coverage --coverage.provider istanbul --coverage.include src/", "migrate:test:latest": "node --experimental-specifier-resolution=node --loader ts-node/esm ./scripts/migrate.ts test latest", "migrate:test:none": "node --experimental-specifier-resolution=node --loader ts-node/esm ./scripts/migrate.ts test none", "migrate:test:down": "node --experimental-specifier-resolution=node --loader ts-node/esm ./scripts/migrate.ts test down", "lint": "eslint .", "lint:fix": "eslint . --fix", "prettier": "prettier --check **/*.ts", "prettier:fix": "prettier --write **/**/*.ts", "prepare": "husky", "deploy": "wrangler publish" }, "type": "module", "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.4.25", "@cloudflare/workers-types": "^4.20240821.1", "@faker-js/faker": "^8.4.1", "@types/bcryptjs": "^2.4.6", "@types/eslint__js": "^8.42.3", "@typescript-eslint/parser": "^8.2.0", "cross-env": "^7.0.3", "dotenv": "^16.4.5", "esbuild": "^0.23.1", "eslint": "^9.9.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-import-x": "^3.1.0", "eslint-plugin-node": "^11.1.0", "eslint-plugin-prettier": "^5.2.1", "eslint-plugin-vitest": "^0.5.4", "globals": "^15.9.0", "husky": "^9.1.5", "mockdate": "^3.0.5", "ts-node": "^10.9.2", "typescript": "^5.5.4", "typescript-eslint": "^8.2.0", "vitest": "1.5.0", "wrangler": "^3.72.2" }, "dependencies": { "@aws-sdk/client-ses": "^3.637.0", "@hono/sentry": "^1.2.0", "@planetscale/database": "^1.19.0", "@smithy/types": "^3.3.0", "@tsndr/cloudflare-worker-jwt": "2.5.3", "@vitest/coverage-istanbul": "^1.5.0", "bcryptjs": "^2.4.3", "dayjs": "^1.11.13", "hono": "^4.5.8", "http-status": "^1.7.4", "kysely": "^0.27.4", "kysely-planetscale": "^1.5", "nanoid": "^5.0.7", "toucan-js": "4.0.0", "worker-auth-providers": "^0.0.13", "zod": "^3.23.8", "zod-validation-error": "^3.3.1" } } ================================================ FILE: scripts/migrate.ts ================================================ /* eslint no-console: "off" */ import { promises as fs } from 'fs' import * as path from 'path' import { fileURLToPath } from 'url' import * as dotenv from 'dotenv' import { Migrator, FileMigrationProvider, NO_MIGRATIONS } from 'kysely' import { Kysely } from 'kysely' import { PlanetScaleDialect } from 'kysely-planetscale' import { User } from '../src/models/user.model' const envFile = { dev: '.env', test: '.env.test' } const __filename = fileURLToPath(import.meta.url) dotenv.config({ path: path.join(path.dirname(__filename), `../${envFile[process.argv[2]]}`) }) interface Database { user: User } const db = new Kysely({ dialect: new PlanetScaleDialect({ username: process.env.DATABASE_USERNAME, password: process.env.DATABASE_PASSWORD, host: process.env.DATABASE_HOST }) }) const migrator = new Migrator({ db, provider: new FileMigrationProvider({ fs, path, migrationFolder: path.join(path.dirname(__filename), '../migrations') }) }) async function migrateToLatest() { const { error, results } = await migrator.migrateToLatest() results?.forEach((it) => { if (it.status === 'Success') { console.log(`migration '${it.migrationName}' was executed successfully`) } else if (it.status === 'Error') { console.error(`failed to execute migration "${it.migrationName}"`) } }) if (error) { console.error('failed to migrate') console.error(error) process.exit(1) } await db.destroy() } async function migrateDown() { const { error, results } = await migrator.migrateDown() results?.forEach((it) => { if (it.status === 'Success') { console.log(`migration '${it.migrationName}' was reverted successfully`) } else if (it.status === 'Error') { console.error(`failed to execute migration "${it.migrationName}"`) } }) if (error) { console.error('failed to migrate') console.error(error) process.exit(1) } await db.destroy() } async function migrateNone() { const { error, results } = await migrator.migrateTo(NO_MIGRATIONS) results?.forEach((it) => { if (it.status === 'Success') { console.log(`migration '${it.migrationName}' was reverted successfully`) } else if (it.status === 'Error') { console.error(`failed to execute migration "${it.migrationName}"`) } }) if (error) { console.error('failed to migrate') console.error(error) process.exit(1) } await db.destroy() } const myArgs = process.argv[3] if (myArgs === 'down') { await migrateDown() } else if (myArgs === 'latest') { await migrateToLatest() } else if (myArgs === 'none') { await migrateNone() } ================================================ FILE: src/config/authProviders.ts ================================================ export const authProviders = { GITHUB: 'github', SPOTIFY: 'spotify', DISCORD: 'discord', GOOGLE: 'google', FACEBOOK: 'facebook', APPLE: 'apple' } as const ================================================ FILE: src/config/config.ts ================================================ import httpStatus from 'http-status' import { ZodError, z } from 'zod' import { Environment } from '../../bindings' import { ApiError } from '../utils/api-error' import { generateZodErrorMessage } from '../utils/zod' const envVarsSchema = z.object({ ENV: z.union([z.literal('production'), z.literal('development'), z.literal('test')]), DATABASE_NAME: z.string(), DATABASE_USERNAME: z.string(), DATABASE_PASSWORD: z.string(), DATABASE_HOST: z.string(), JWT_SECRET: z.string(), JWT_ACCESS_EXPIRATION_MINUTES: z.coerce.number().default(30), JWT_REFRESH_EXPIRATION_DAYS: z.coerce.number().default(30), JWT_RESET_PASSWORD_EXPIRATION_MINUTES: z.coerce.number().default(10), JWT_VERIFY_EMAIL_EXPIRATION_MINUTES: z.coerce.number().default(10), AWS_ACCESS_KEY_ID: z.string(), AWS_SECRET_ACCESS_KEY: z.string(), AWS_REGION: z.string(), EMAIL_SENDER: z.string(), OAUTH_WEB_REDIRECT_URL: z.string(), OAUTH_ANDROID_REDIRECT_URL: z.string(), OAUTH_IOS_REDIRECT_URL: z.string(), OAUTH_GITHUB_CLIENT_ID: z.string(), OAUTH_GITHUB_CLIENT_SECRET: z.string(), OAUTH_GOOGLE_CLIENT_ID: z.string(), OAUTH_GOOGLE_CLIENT_SECRET: z.string(), OAUTH_DISCORD_CLIENT_ID: z.string(), OAUTH_DISCORD_CLIENT_SECRET: z.string(), OAUTH_SPOTIFY_CLIENT_ID: z.string(), OAUTH_SPOTIFY_CLIENT_SECRET: z.string(), OAUTH_FACEBOOK_CLIENT_ID: z.string(), OAUTH_FACEBOOK_CLIENT_SECRET: z.string(), OAUTH_APPLE_CLIENT_ID: z.string(), OAUTH_APPLE_PRIVATE_KEY: z.string(), OAUTH_APPLE_KEY_ID: z.string(), OAUTH_APPLE_TEAM_ID: z.string(), OAUTH_APPLE_JWT_ACCESS_EXPIRATION_MINUTES: z.coerce.number().default(30), OAUTH_APPLE_REDIRECT_URL: z.string() }) export type EnvVarsSchemaType = z.infer export interface Config { env: 'production' | 'development' | 'test' database: { name: string username: string password: string host: string } jwt: { secret: string accessExpirationMinutes: number refreshExpirationDays: number resetPasswordExpirationMinutes: number verifyEmailExpirationMinutes: number } aws: { accessKeyId: string secretAccessKey: string region: string } email: { sender: string } oauth: { platform: { web: { redirectUrl: string } android: { redirectUrl: string } ios: { redirectUrl: string } } provider: { github: { clientId: string clientSecret: string } google: { clientId: string clientSecret: string } spotify: { clientId: string clientSecret: string } discord: { clientId: string clientSecret: string } facebook: { clientId: string clientSecret: string } apple: { clientId: string privateKey: string keyId: string teamId: string jwtAccessExpirationMinutes: number redirectUrl: string } } } } let config: Config export const getConfig = (env: Environment['Bindings']) => { if (config) { return config } let envVars: EnvVarsSchemaType try { envVars = envVarsSchema.parse(env) } catch (err) { if (env.ENV && env.ENV === 'production') { throw new ApiError(httpStatus.INTERNAL_SERVER_ERROR, 'Invalid server configuration') } if (err instanceof ZodError) { const errorMessage = generateZodErrorMessage(err) throw new ApiError(httpStatus.INTERNAL_SERVER_ERROR, errorMessage) } throw err } config = { env: envVars.ENV, database: { name: envVars.DATABASE_NAME, username: envVars.DATABASE_USERNAME, password: envVars.DATABASE_PASSWORD, host: envVars.DATABASE_HOST }, jwt: { secret: envVars.JWT_SECRET, accessExpirationMinutes: envVars.JWT_ACCESS_EXPIRATION_MINUTES, refreshExpirationDays: envVars.JWT_REFRESH_EXPIRATION_DAYS, resetPasswordExpirationMinutes: envVars.JWT_RESET_PASSWORD_EXPIRATION_MINUTES, verifyEmailExpirationMinutes: envVars.JWT_VERIFY_EMAIL_EXPIRATION_MINUTES }, aws: { accessKeyId: envVars.AWS_ACCESS_KEY_ID, secretAccessKey: envVars.AWS_SECRET_ACCESS_KEY, region: envVars.AWS_REGION }, email: { sender: envVars.EMAIL_SENDER }, oauth: { platform: { web: { redirectUrl: envVars.OAUTH_WEB_REDIRECT_URL }, android: { redirectUrl: envVars.OAUTH_ANDROID_REDIRECT_URL }, ios: { redirectUrl: envVars.OAUTH_IOS_REDIRECT_URL } }, provider: { github: { clientId: envVars.OAUTH_GITHUB_CLIENT_ID, clientSecret: envVars.OAUTH_GITHUB_CLIENT_SECRET }, google: { clientId: envVars.OAUTH_GOOGLE_CLIENT_ID, clientSecret: envVars.OAUTH_GOOGLE_CLIENT_SECRET }, spotify: { clientId: envVars.OAUTH_SPOTIFY_CLIENT_ID, clientSecret: envVars.OAUTH_SPOTIFY_CLIENT_SECRET }, discord: { clientId: envVars.OAUTH_DISCORD_CLIENT_ID, clientSecret: envVars.OAUTH_DISCORD_CLIENT_SECRET }, facebook: { clientId: envVars.OAUTH_FACEBOOK_CLIENT_ID, clientSecret: envVars.OAUTH_FACEBOOK_CLIENT_SECRET }, apple: { clientId: envVars.OAUTH_APPLE_CLIENT_ID, privateKey: envVars.OAUTH_APPLE_PRIVATE_KEY, keyId: envVars.OAUTH_APPLE_KEY_ID, teamId: envVars.OAUTH_APPLE_TEAM_ID, jwtAccessExpirationMinutes: envVars.OAUTH_APPLE_JWT_ACCESS_EXPIRATION_MINUTES, redirectUrl: envVars.OAUTH_APPLE_REDIRECT_URL } } } } return config } ================================================ FILE: src/config/database.ts ================================================ import { Kysely } from 'kysely' import { PlanetScaleDialect } from 'kysely-planetscale' import { AuthProviderTable } from '../tables/oauth.table' import { OneTimeOauthCodeTable } from '../tables/one-time-oauth-code.table' import { UserTable } from '../tables/user.table' import { Config } from './config' let dbClient: Kysely export interface Database { user: UserTable authorisations: AuthProviderTable one_time_oauth_code: OneTimeOauthCodeTable } export const getDBClient = (databaseConfig: Config['database']): Kysely => { dbClient = dbClient || new Kysely({ dialect: new PlanetScaleDialect({ username: databaseConfig.username, password: databaseConfig.password, host: databaseConfig.host, fetch: (url, init) => { // TODO: REMOVE. // Remove cache header // https://github.com/cloudflare/workerd/issues/698 // eslint-disable-next-line @typescript-eslint/no-explicit-any delete (init as any)['cache'] return fetch(url, init) } }) }) return dbClient } ================================================ FILE: src/config/roles.ts ================================================ export const roleRights = { user: [], admin: ['getUsers', 'manageUsers'] } as const export const roles = Object.keys(roleRights) as Role[] export type Permission = (typeof roleRights)[keyof typeof roleRights][number] export type Role = keyof typeof roleRights ================================================ FILE: src/config/tokens.ts ================================================ export const tokenTypes = { ACCESS: 'access', REFRESH: 'refresh', RESET_PASSWORD: 'resetPassword', VERIFY_EMAIL: 'verifyEmail' } as const export type TokenType = (typeof tokenTypes)[keyof typeof tokenTypes] ================================================ FILE: src/controllers/auth/auth.controller.ts ================================================ import { Handler } from 'hono' import httpStatus from 'http-status' import { Environment } from '../../../bindings' import { getConfig } from '../../config/config' import * as authService from '../../services/auth.service' import * as emailService from '../../services/email.service' import * as tokenService from '../../services/token.service' import * as userService from '../../services/user.service' import { ApiError } from '../../utils/api-error' import * as authValidation from '../../validations/auth.validation' export const register: Handler = async (c) => { const config = getConfig(c.env) const bodyParse = await c.req.json() const body = await authValidation.register.parseAsync(bodyParse) const user = await authService.register(body, config.database) const tokens = await tokenService.generateAuthTokens(user, config.jwt) return c.json({ user, tokens }, httpStatus.CREATED) } export const login: Handler = async (c) => { const config = getConfig(c.env) const bodyParse = await c.req.json() const { email, password } = authValidation.login.parse(bodyParse) const user = await authService.loginUserWithEmailAndPassword(email, password, config.database) const tokens = await tokenService.generateAuthTokens(user, config.jwt) return c.json({ user, tokens }, httpStatus.OK) } export const refreshTokens: Handler = async (c) => { const config = getConfig(c.env) const bodyParse = await c.req.json() const { refresh_token } = authValidation.refreshTokens.parse(bodyParse) const tokens = await authService.refreshAuth(refresh_token, config) return c.json({ ...tokens }, httpStatus.OK) } export const forgotPassword: Handler = async (c) => { const bodyParse = await c.req.json() const config = getConfig(c.env) const { email } = authValidation.forgotPassword.parse(bodyParse) const user = await userService.getUserByEmail(email, config.database) // Don't let bad actors know if the email is registered by throwing if the user exists if (user) { const resetPasswordToken = await tokenService.generateResetPasswordToken(user, config.jwt) await emailService.sendResetPasswordEmail( user.email, { name: user.name || '', token: resetPasswordToken }, config ) } c.status(httpStatus.NO_CONTENT) return c.body(null) } export const resetPassword: Handler = async (c) => { const queryParse = c.req.query() const bodyParse = await c.req.json() const config = getConfig(c.env) const { query, body } = await authValidation.resetPassword.parseAsync({ query: queryParse, body: bodyParse }) await authService.resetPassword(query.token, body.password, config) c.status(httpStatus.NO_CONTENT) return c.body(null) } export const sendVerificationEmail: Handler = async (c) => { const config = getConfig(c.env) const payload = c.get('payload') const userId = payload.sub if (!userId) { throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate') } // Don't let bad actors know if the email is registered by returning an error if the email // is already verified try { const user = await userService.getUserById(userId, config.database) if (!user || user.is_email_verified) { throw new Error() } const verifyEmailToken = await tokenService.generateVerifyEmailToken(user, config.jwt) await emailService.sendVerificationEmail( user.email, { name: user.name || '', token: verifyEmailToken }, config ) } catch {} c.status(httpStatus.NO_CONTENT) return c.body(null) } export const verifyEmail: Handler = async (c) => { const config = getConfig(c.env) const queryParse = c.req.query() const { token } = authValidation.verifyEmail.parse(queryParse) await authService.verifyEmail(token, config) c.status(httpStatus.NO_CONTENT) return c.body(null) } export const getAuthorisations: Handler = async (c) => { const config = getConfig(c.env) const payload = c.get('payload') if (!payload.sub) { throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate') } const userId = payload.sub const authorisations = await userService.getAuthorisations(userId, config.database) return c.json(authorisations, httpStatus.OK) } ================================================ FILE: src/controllers/auth/oauth/apple.controller.ts ================================================ // TODO: Handle users using private email relay // https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/ // authenticating_users_with_sign_in_with_apple // Also handle users without email // refactor import { decode } from '@tsndr/cloudflare-worker-jwt' import { Handler } from 'hono' import type { StatusCode } from 'hono/utils/http-status' import httpStatus from 'http-status' import { apple } from 'worker-auth-providers' import { Environment } from '../../../../bindings' import { authProviders } from '../../../config/authProviders' import { Config, getConfig } from '../../../config/config' import { AppleUser } from '../../../models/oauth/apple-user.model' import * as authService from '../../../services/auth.service' import { getIdTokenFromCode } from '../../../services/oauth/apple.service' import * as tokenService from '../../../services/token.service' import { ApiError } from '../../../utils/api-error' import * as authValidation from '../../../validations/auth.validation' import { deleteOauthLink, getRedirectUrl, parseState } from './oauth.controller' type AppleJWT = { iss: string aud: string exp: number iat: number sub: string at_hash: string email: string email_verified: string is_private_email: string auth_time: number nonce_supported: boolean } const getAppleUser = async (code: string | null, config: Config) => { if (!code) { throw new ApiError(httpStatus.BAD_REQUEST as StatusCode, 'Bad request') } const appleClientSecret = await apple.convertPrivateKeyToClientSecret({ privateKey: config.oauth.provider.apple.privateKey, keyIdentifier: config.oauth.provider.apple.keyId, teamId: config.oauth.provider.apple.teamId, clientId: config.oauth.provider.apple.clientId, expAfter: config.oauth.provider.apple.jwtAccessExpirationMinutes * 60 }) const idToken = await getIdTokenFromCode( code, config.oauth.provider.apple.clientId, appleClientSecret, config.oauth.provider.apple.redirectUrl ) const userData = decode(idToken).payload as AppleJWT if (!userData.email) { throw new ApiError(httpStatus.UNAUTHORIZED, 'Unauthorized') } const appleUser = new AppleUser(userData) return appleUser } export const appleRedirect: Handler = async (c) => { const config = getConfig(c.env) const { state } = authValidation.oauthRedirect.parse(c.req.query()) parseState(state) const location = await apple.redirect({ options: { clientId: config.oauth.provider.apple.clientId, redirectTo: config.oauth.provider.apple.redirectUrl, scope: ['email'], responseMode: 'form_post', state: state } }) return c.redirect(location, httpStatus.FOUND) } export const appleCallback: Handler = async (c) => { const config = getConfig(c.env) const formData = await c.req.formData() const state = formData.get('state') if (!state) { const redirect = new URL('?error=Something went wrong', config.oauth.platform.web.redirectUrl) .href return c.redirect(redirect, httpStatus.FOUND) } // Set a base redirect url to web in case of no platform info being passed let redirectBase = config.oauth.platform.web.redirectUrl try { redirectBase = getRedirectUrl(state, config) const appleUser = await getAppleUser(formData.get('code'), config) const user = await authService.loginOrCreateUserWithOauth(appleUser, config.database) const tokens = await tokenService.generateAuthTokens(user, config.jwt) const oneTimeCode = await tokenService.createOneTimeOauthCode(user.id, tokens, config) const redirect = new URL(`?oneTimeCode=${oneTimeCode}&state=${state}`, redirectBase).href return c.redirect(redirect, httpStatus.FOUND) } catch (error) { const message = error instanceof ApiError ? error.message : 'Something went wrong' const redirect = new URL(`?error=${message}&state=${state}`, redirectBase).href return c.redirect(redirect, httpStatus.FOUND) } } export const linkApple: Handler = async (c) => { const config = getConfig(c.env) const payload = c.get('payload') const userId = payload.sub if (!userId) { throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate') } const bodyParse = await c.req.json() const { code } = authValidation.linkApple.parse(bodyParse) const appleUser = await getAppleUser(code, config) await authService.linkUserWithOauth(userId, appleUser, config.database) c.status(httpStatus.NO_CONTENT as StatusCode) return c.body(null) } export const deleteAppleLink: Handler = async (c) => { return deleteOauthLink(c, authProviders.APPLE) } ================================================ FILE: src/controllers/auth/oauth/discord.controller.ts ================================================ import { Handler } from 'hono' import httpStatus from 'http-status' import { discord } from 'worker-auth-providers' import { Environment } from '../../../../bindings' import { authProviders } from '../../../config/authProviders' import { getConfig } from '../../../config/config' import { DiscordUserType } from '../../../types/oauth.types' import * as authValidation from '../../../validations/auth.validation' import { oauthCallback, oauthLink, deleteOauthLink, validateCallbackBody, getRedirectUrl } from './oauth.controller' export const discordRedirect: Handler = async (c) => { const config = getConfig(c.env) const { state } = authValidation.oauthRedirect.parse(c.req.query()) const redirectUrl = getRedirectUrl(state, config) const location = await discord.redirect({ options: { clientId: config.oauth.provider.discord.clientId, redirectUrl: redirectUrl, state: state, scope: 'identify email' } }) return c.redirect(location, httpStatus.FOUND) } export const discordCallback: Handler = async (c) => { const config = getConfig(c.env) const bodyParse = await c.req.json() const { platform, code } = authValidation.oauthCallback.parse(bodyParse) const redirectUrl = config.oauth.platform[platform].redirectUrl const request = await validateCallbackBody(c, code) const oauthRequest = discord.users({ options: { clientId: config.oauth.provider.discord.clientId, clientSecret: config.oauth.provider.discord.clientSecret, redirectUrl: redirectUrl }, request }) as Promise<{ user: DiscordUserType; tokens: unknown }> return oauthCallback(c, oauthRequest, authProviders.DISCORD) } export const linkDiscord: Handler = async (c) => { const config = getConfig(c.env) const bodyParse = await c.req.json() const { platform, code } = authValidation.oauthCallback.parse(bodyParse) const redirectUrl = config.oauth.platform[platform].redirectUrl const request = await validateCallbackBody(c, code) const oauthRequest = discord.users({ options: { clientId: config.oauth.provider.discord.clientId, clientSecret: config.oauth.provider.discord.clientSecret, redirectUrl: redirectUrl }, request }) as Promise<{ user: DiscordUserType; tokens: unknown }> return oauthLink(c, oauthRequest, authProviders.DISCORD) } export const deleteDiscordLink: Handler = async (c) => { return deleteOauthLink(c, authProviders.DISCORD) } ================================================ FILE: src/controllers/auth/oauth/facebook.controller.ts ================================================ import { Handler } from 'hono' import httpStatus from 'http-status' import { facebook } from 'worker-auth-providers' import { Environment } from '../../../../bindings' import { authProviders } from '../../../config/authProviders' import { getConfig } from '../../../config/config' import * as facebookService from '../../../services/oauth/facebook.service' import * as authValidation from '../../../validations/auth.validation' import { oauthCallback, oauthLink, deleteOauthLink, validateCallbackBody, getRedirectUrl } from './oauth.controller' export const facebookRedirect: Handler = async (c) => { const config = getConfig(c.env) const { state } = authValidation.oauthRedirect.parse(c.req.query()) const redirectUrl = getRedirectUrl(state, config) const location = await facebookService.redirect({ clientId: config.oauth.provider.facebook.clientId, redirectUrl: redirectUrl, state: state }) return c.redirect(location, httpStatus.FOUND) } export const facebookCallback: Handler = async (c) => { const config = getConfig(c.env) const bodyParse = await c.req.json() const { platform, code } = authValidation.oauthCallback.parse(bodyParse) const redirectUrl = config.oauth.platform[platform].redirectUrl const request = await validateCallbackBody(c, code) const oauthRequest = facebook.users({ options: { clientId: config.oauth.provider.facebook.clientId, clientSecret: config.oauth.provider.facebook.clientSecret, redirectUrl: redirectUrl }, request }) return oauthCallback(c, oauthRequest, authProviders.FACEBOOK) } export const linkFacebook: Handler = async (c) => { const config = getConfig(c.env) const bodyParse = await c.req.json() const { platform, code } = authValidation.oauthCallback.parse(bodyParse) const request = await validateCallbackBody(c, code) const redirectUrl = config.oauth.platform[platform].redirectUrl const oauthRequest = facebook.users({ options: { clientId: config.oauth.provider.facebook.clientId, clientSecret: config.oauth.provider.facebook.clientSecret, redirectUrl: redirectUrl }, request }) return oauthLink(c, oauthRequest, authProviders.FACEBOOK) } export const deleteFacebookLink: Handler = async (c) => { return deleteOauthLink(c, authProviders.FACEBOOK) } ================================================ FILE: src/controllers/auth/oauth/github.controller.ts ================================================ import { Handler } from 'hono' import httpStatus from 'http-status' import { github } from 'worker-auth-providers' import { Environment } from '../../../../bindings' import { authProviders } from '../../../config/authProviders' import { getConfig } from '../../../config/config' import * as githubService from '../../../services/oauth/github.service' import * as authValidation from '../../../validations/auth.validation' import { oauthCallback, oauthLink, deleteOauthLink, validateCallbackBody, getRedirectUrl } from './oauth.controller' export const githubRedirect: Handler = async (c) => { const config = getConfig(c.env) const { state } = authValidation.oauthRedirect.parse(c.req.query()) const redirectUrl = getRedirectUrl(state, config) const location = await githubService.redirect({ clientId: config.oauth.provider.github.clientId, redirectTo: redirectUrl, state: state }) return c.redirect(location, httpStatus.FOUND) } export const githubCallback: Handler = async (c) => { const config = getConfig(c.env) const bodyParse = await c.req.json() const { platform, code } = authValidation.oauthCallback.parse(bodyParse) const request = await validateCallbackBody(c, code) const redirectUrl = config.oauth.platform[platform].redirectUrl const oauthRequest = github.users({ options: { clientId: config.oauth.provider.github.clientId, clientSecret: config.oauth.provider.github.clientSecret, redirectUrl: redirectUrl }, request }) return oauthCallback(c, oauthRequest, authProviders.GITHUB) } export const linkGithub: Handler = async (c) => { const config = getConfig(c.env) const bodyParse = await c.req.json() const { platform, code } = authValidation.oauthCallback.parse(bodyParse) const request = await validateCallbackBody(c, code) const redirectUrl = config.oauth.platform[platform].redirectUrl const oauthRequest = github.users({ options: { clientId: config.oauth.provider.github.clientId, clientSecret: config.oauth.provider.github.clientSecret, redirectUrl: redirectUrl }, request }) return oauthLink(c, oauthRequest, authProviders.GITHUB) } export const deleteGithubLink: Handler = async (c) => { return deleteOauthLink(c, authProviders.GITHUB) } ================================================ FILE: src/controllers/auth/oauth/google.controller.ts ================================================ import { Handler } from 'hono' import httpStatus from 'http-status' import { google } from 'worker-auth-providers' import { Environment } from '../../../../bindings' import { authProviders } from '../../../config/authProviders' import { getConfig } from '../../../config/config' import { GoogleUserType } from '../../../types/oauth.types' import * as authValidation from '../../../validations/auth.validation' import { oauthCallback, oauthLink, deleteOauthLink, validateCallbackBody, getRedirectUrl } from './oauth.controller' export const googleRedirect: Handler = async (c) => { const config = getConfig(c.env) const { state } = authValidation.oauthRedirect.parse(c.req.query()) const redirectUrl = getRedirectUrl(state, config) const location = await google.redirect({ options: { clientId: config.oauth.provider.google.clientId, redirectUrl: redirectUrl, state: state } }) return c.redirect(location, httpStatus.FOUND) } export const googleCallback: Handler = async (c) => { const config = getConfig(c.env) const bodyParse = await c.req.json() const { platform, code } = authValidation.oauthCallback.parse(bodyParse) const request = await validateCallbackBody(c, code) const redirectUrl = config.oauth.platform[platform].redirectUrl const oauthRequest = google.users({ options: { clientId: config.oauth.provider.google.clientId, clientSecret: config.oauth.provider.google.clientSecret, redirectUrl: redirectUrl }, request }) as Promise<{ user: GoogleUserType; tokens: unknown }> return oauthCallback(c, oauthRequest, authProviders.GOOGLE) } export const linkGoogle: Handler = async (c) => { const config = getConfig(c.env) const bodyParse = await c.req.json() const { platform, code } = authValidation.oauthCallback.parse(bodyParse) const request = await validateCallbackBody(c, code) const redirectUrl = config.oauth.platform[platform].redirectUrl const oauthRequest = google.users({ options: { clientId: config.oauth.provider.google.clientId, clientSecret: config.oauth.provider.google.clientSecret, redirectUrl: redirectUrl }, request }) as Promise<{ user: GoogleUserType; tokens: unknown }> return oauthLink(c, oauthRequest, authProviders.GOOGLE) } export const deleteGoogleLink: Handler = async (c) => { return deleteOauthLink(c, authProviders.GOOGLE) } ================================================ FILE: src/controllers/auth/oauth/oauth.controller.ts ================================================ import { Context, Handler } from 'hono' import type { StatusCode } from 'hono/utils/http-status' import httpStatus from 'http-status' import { Environment } from '../../../../bindings' import { Config, getConfig } from '../../../config/config' import { providerUserFactory } from '../../../factories/oauth.factory' import { OAuthUserModel } from '../../../models/oauth/oauth-base.model' import * as authService from '../../../services/auth.service' import * as tokenService from '../../../services/token.service' import * as userService from '../../../services/user.service' import { AuthProviderType, OauthUserTypes } from '../../../types/oauth.types' import { ApiError } from '../../../utils/api-error' import * as authValidation from '../../../validations/auth.validation' type State = { platform: 'web' | 'android' | 'ios' } export const parseState = (state: string) => { try { const decodedState = JSON.parse(atob(state)) as State authValidation.stateValidation.parse(decodedState) return decodedState } catch { throw new ApiError(httpStatus.BAD_REQUEST as StatusCode, 'Bad request') } } export const getRedirectUrl = (state: string, config: Config) => { try { const decodedState = parseState(state) const platform = decodedState.platform return config.oauth.platform[platform].redirectUrl } catch { throw new ApiError(httpStatus.BAD_REQUEST as StatusCode, 'Bad request') } } export const oauthCallback = async ( c: Context, oauthRequest: Promise<{ user: OauthUserTypes[T]; tokens: unknown }>, providerType: T ): Promise => { const config = getConfig(c.env) let providerUser: OAuthUserModel try { const result = await oauthRequest const UserModel = providerUserFactory[providerType] providerUser = new UserModel(result.user) } catch { throw new ApiError(httpStatus.UNAUTHORIZED as StatusCode, 'Unauthorized') } const user = await authService.loginOrCreateUserWithOauth(providerUser, config.database) const tokens = await tokenService.generateAuthTokens(user, config.jwt) return c.json({ user, tokens }, httpStatus.OK as StatusCode) } export const oauthLink = async ( c: Context, oauthRequest: Promise<{ user: OauthUserTypes[T]; tokens: unknown }>, providerType: T ): Promise => { const payload = c.get('payload') const userId = payload.sub if (!userId) { throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate') } const config = getConfig(c.env) let providerUser: OAuthUserModel try { const result = await oauthRequest const UserModel = providerUserFactory[providerType] providerUser = new UserModel(result.user) } catch { throw new ApiError(httpStatus.UNAUTHORIZED as StatusCode, 'Unauthorized') } await authService.linkUserWithOauth(userId, providerUser, config.database) c.status(httpStatus.NO_CONTENT as StatusCode) return c.body(null) } export const deleteOauthLink = async ( c: Context, provider: AuthProviderType ): Promise => { const payload = c.get('payload') const userId = payload.sub if (!userId) { throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate') } const config = getConfig(c.env) await authService.deleteOauthLink(userId, provider, config.database) c.status(httpStatus.NO_CONTENT as StatusCode) return c.body(null) } export const validateCallbackBody = async ( c: Context, code: string ): Promise => { const url = new URL(c.req.url) url.searchParams.set('code', code) const request = new Request(url.toString()) return request } export const validateOauthOneTimeCode: Handler = async (c) => { const config = getConfig(c.env) const bodyParse = await c.req.json() const { code } = authValidation.validateOneTimeCode.parse(bodyParse) const oauthCode = await tokenService.getOneTimeOauthCode(code, config) const user = await userService.getUserById(oauthCode.user_id, config.database) const tokenResponse = { access: { token: oauthCode.access_token, expires: oauthCode.access_token_expires_at }, refresh: { token: oauthCode.refresh_token, expires: oauthCode.refresh_token_expires_at } } return c.json({ user, tokens: tokenResponse }, httpStatus.OK as StatusCode) } ================================================ FILE: src/controllers/auth/oauth/spotify.controller.ts ================================================ import { Handler } from 'hono' import httpStatus from 'http-status' import { spotify } from 'worker-auth-providers' import { Environment } from '../../../../bindings' import { authProviders } from '../../../config/authProviders' import { getConfig } from '../../../config/config' import * as spotifyService from '../../../services/oauth/spotify.service' import { SpotifyUserType } from '../../../types/oauth.types' import * as authValidation from '../../../validations/auth.validation' import { oauthCallback, oauthLink, deleteOauthLink, validateCallbackBody, getRedirectUrl } from './oauth.controller' export const spotifyRedirect: Handler = async (c) => { const config = getConfig(c.env) const { state } = authValidation.oauthRedirect.parse(c.req.query()) const redirectUrl = getRedirectUrl(state, config) const location = await spotifyService.redirect({ clientId: config.oauth.provider.spotify.clientId, redirectUrl: redirectUrl, state: state, scope: 'user-read-email' }) return c.redirect(location, httpStatus.FOUND) } export const spotifyCallback: Handler = async (c) => { const config = getConfig(c.env) const bodyParse = await c.req.json() const { platform, code } = authValidation.oauthCallback.parse(bodyParse) const request = await validateCallbackBody(c, code) const redirectUrl = config.oauth.platform[platform].redirectUrl const oauthRequest = spotify.users({ options: { clientId: config.oauth.provider.spotify.clientId, clientSecret: config.oauth.provider.spotify.clientSecret, redirectUrl: redirectUrl }, request }) as Promise<{ user: SpotifyUserType; tokens: unknown }> return oauthCallback(c, oauthRequest, authProviders.SPOTIFY) } export const linkSpotify: Handler = async (c) => { const config = getConfig(c.env) const bodyParse = await c.req.json() const { platform, code } = authValidation.oauthCallback.parse(bodyParse) const request = await validateCallbackBody(c, code) const redirectUrl = config.oauth.platform[platform].redirectUrl const oauthRequest = spotify.users({ options: { clientId: config.oauth.provider.spotify.clientId, clientSecret: config.oauth.provider.spotify.clientSecret, redirectUrl: redirectUrl }, request }) as Promise<{ user: SpotifyUserType; tokens: unknown }> return oauthLink(c, oauthRequest, authProviders.SPOTIFY) } export const deleteSpotifyLink: Handler = async (c) => { return deleteOauthLink(c, authProviders.SPOTIFY) } ================================================ FILE: src/controllers/user.controller.ts ================================================ import { Handler } from 'hono' import httpStatus from 'http-status' import { Environment } from '../../bindings' import { getConfig } from '../config/config' import * as userService from '../services/user.service' import { ApiError } from '../utils/api-error' import * as userValidation from '../validations/user.validation' export const createUser: Handler = async (c) => { const config = getConfig(c.env) const bodyParse = await c.req.json() const body = await userValidation.createUser.parseAsync(bodyParse) const user = await userService.createUser(body, config.database) return c.json(user, httpStatus.CREATED) } export const getUsers: Handler = async (c) => { const config = getConfig(c.env) const queryParse = c.req.query() const query = userValidation.getUsers.parse(queryParse) const filter = { email: query.email } const options = { sortBy: query.sort_by, limit: query.limit, page: query.page } const result = await userService.queryUsers(filter, options, config.database) return c.json(result, httpStatus.OK) } export const getUser: Handler = async (c) => { const config = getConfig(c.env) const paramsParse = c.req.param() const params = userValidation.getUser.parse(paramsParse) const user = await userService.getUserById(params.userId, config.database) if (!user) { throw new ApiError(httpStatus.NOT_FOUND, 'User not found') } return c.json(user, httpStatus.OK) } export const updateUser: Handler = async (c) => { const config = getConfig(c.env) const paramsParse = c.req.param() const bodyParse = await c.req.json() const { params, body } = userValidation.updateUser.parse({ params: paramsParse, body: bodyParse }) const user = await userService.updateUserById(params.userId, body, config.database) return c.json(user, httpStatus.OK) } export const deleteUser: Handler = async (c) => { const config = getConfig(c.env) const paramsParse = c.req.param() const params = userValidation.deleteUser.parse(paramsParse) await userService.deleteUserById(params.userId, config.database) c.status(httpStatus.NO_CONTENT) return c.body(null) } ================================================ FILE: src/durable-objects/rate-limiter.do.ts ================================================ import dayjs from 'dayjs' import { Context, Hono } from 'hono' import httpStatus from 'http-status' import { z, ZodError } from 'zod' import { fromError } from 'zod-validation-error' import { Environment } from '../../bindings' interface Config { scope: string key: string limit: number interval: number } const configValidation = z.object({ scope: z.string(), key: z.string(), limit: z.number().int().positive(), interval: z.number().int().positive() }) export class RateLimiter { state: DurableObjectState env: Environment['Bindings'] app: Hono = new Hono() constructor(state: DurableObjectState, env: Environment['Bindings']) { this.state = state this.env = env this.app.post('/', async (c) => { await this.setAlarm() let config try { config = await this.getConfig(c) } catch (err: unknown) { let errorMessage if (err instanceof ZodError) { errorMessage = fromError(err) } return c.json( { statusCode: httpStatus.BAD_REQUEST, error: errorMessage }, httpStatus.BAD_REQUEST ) } const rate = await this.calculateRate(config) const blocked = this.isRateLimited(rate, config.limit) const headers = this.getHeaders(blocked, config) const remaining = blocked ? 0 : Math.floor(config.limit - rate - 1) // If the remaining requests is negative set it to 0 to indicate 100% throughput const remainingHeader = remaining >= 0 ? remaining : 0 return c.json( { blocked, remaining: remainingHeader, expires: headers.expires }, httpStatus.OK, headers ) }) } async alarm() { const values = await this.state.storage.list() for await (const [key, _value] of values) { const [_scope, _key, _limit, interval, timestamp] = key.split('|') const currentWindow = Math.floor(this.nowUnix() / parseInt(interval)) const timestampLessThan = currentWindow - 2 // expire all keys after 2 intervals have passed if (parseInt(timestamp) < timestampLessThan) { await this.state.storage.delete(key) } } } async setAlarm() { const alarm = await this.state.storage.getAlarm() if (!alarm) { this.state.storage.setAlarm(dayjs().add(6, 'hours').toDate()) } } async getConfig(c: Context) { const body = await c.req.json() const config = configValidation.parse(body) return config } async incrementRequestCount(key: string) { const currentRequestCount = await this.getRequestCount(key) await this.state.storage.put(key, currentRequestCount + 1) } async getRequestCount(key: string): Promise { return parseInt((await this.state.storage.get(key)) as string) || 0 } nowUnix() { return dayjs().unix() } async calculateRate(config: Config) { const keyPrefix = `${config.scope}|${config.key}|${config.limit}|${config.interval}` const currentWindow = Math.floor(this.nowUnix() / config.interval) const distanceFromLastWindow = this.nowUnix() % config.interval const currentKey = `${keyPrefix}|${currentWindow}` const previousKey = `${keyPrefix}|${currentWindow - 1}` const currentCount = await this.getRequestCount(currentKey) const previousCount = (await this.getRequestCount(previousKey)) || 0 const rate = (previousCount * (config.interval - distanceFromLastWindow)) / config.interval + currentCount if (!this.isRateLimited(rate, config.limit)) { await this.incrementRequestCount(currentKey) } return rate } isRateLimited(rate: number, limit: number) { return rate >= limit } getHeaders(blocked: boolean, config: Config) { const expires = this.expirySeconds(config) const retryAfter = this.retryAfter(expires) const headers: { expires: string; 'cache-control'?: string } = { expires: retryAfter.toString() } if (!blocked) { return headers } headers['cache-control'] = `public, max-age=${expires}, s-maxage=${expires}, must-revalidate` return headers } expirySeconds(config: Config) { const currentWindowStart = Math.floor(this.nowUnix() / config.interval) const currentWindowEnd = currentWindowStart + 1 const secondsRemaining = currentWindowEnd * config.interval - this.nowUnix() return secondsRemaining } retryAfter(expires: number) { return dayjs().add(expires, 'seconds').toString() } async fetch(request: Request): Promise { return this.app.fetch(request) } } ================================================ FILE: src/factories/oauth.factory.ts ================================================ import { AppleUser } from '../models/oauth/apple-user.model' import { DiscordUser } from '../models/oauth/discord-user.model' import { FacebookUser } from '../models/oauth/facebook-user.model' import { GithubUser } from '../models/oauth/github-user.model' import { GoogleUser } from '../models/oauth/google-user.model' import { SpotifyUser } from '../models/oauth/spotify-user.model' import { ProviderUserMapping } from '../types/oauth.types' export const providerUserFactory: ProviderUserMapping = { facebook: FacebookUser, discord: DiscordUser, google: GoogleUser, spotify: SpotifyUser, apple: AppleUser, github: GithubUser } ================================================ FILE: src/index.ts ================================================ import { sentry } from '@hono/sentry' import { Hono } from 'hono' import { cors } from 'hono/cors' import httpStatus from 'http-status' import { Environment } from '../bindings' import { errorHandler } from './middlewares/error' import { defaultRoutes } from './routes' import { ApiError } from './utils/api-error' export { RateLimiter } from './durable-objects/rate-limiter.do' const app = new Hono() app.use('*', sentry()) app.use('*', cors()) app.notFound(() => { throw new ApiError(httpStatus.NOT_FOUND, 'Not found') }) app.onError(errorHandler) defaultRoutes.forEach((route) => { app.route(`${route.path}`, route.route) }) export default app ================================================ FILE: src/middlewares/auth.ts ================================================ import jwt, { JwtPayload } from '@tsndr/cloudflare-worker-jwt' import { MiddlewareHandler } from 'hono' import httpStatus from 'http-status' import { Environment } from '../../bindings' import { getConfig } from '../config/config' import { roleRights, Permission, Role } from '../config/roles' import { tokenTypes } from '../config/tokens' import { getUserById } from '../services/user.service' import { ApiError } from '../utils/api-error' const authenticate = async (jwtToken: string, secret: string) => { let authorized = false let payload try { authorized = await jwt.verify(jwtToken, secret) const decoded = jwt.decode(jwtToken) payload = decoded.payload as JwtPayload authorized = authorized && payload.type === tokenTypes.ACCESS } catch {} return { authorized, payload } } export const auth = (...requiredRights: Permission[]): MiddlewareHandler => async (c, next) => { const credentials = c.req.raw.headers.get('Authorization') const config = getConfig(c.env) if (!credentials) { throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate') } const parts = credentials.split(/\s+/) if (parts.length !== 2) { throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate') } const jwtToken = parts[1] const { authorized, payload } = await authenticate(jwtToken, config.jwt.secret) if (!authorized || !payload || !payload.sub) { throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate') } if (requiredRights.length) { const userRights = roleRights[payload.role as Role] const hasRequiredRights = requiredRights.every((requiredRight) => (userRights as unknown as string[]).includes(requiredRight) ) if (!hasRequiredRights && c.req.param('userId') !== payload.sub) { throw new ApiError(httpStatus.FORBIDDEN, 'Forbidden') } } if (!payload.isEmailVerified) { const user = await getUserById(payload.sub, config['database']) if (!user) { throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate') } const url = new URL(c.req.url) if (url.pathname !== '/v1/auth/send-verification-email') { throw new ApiError(httpStatus.FORBIDDEN, 'Please verify your email') } } c.set('payload', payload) await next() } ================================================ FILE: src/middlewares/error.ts ================================================ import { getSentry } from '@hono/sentry' import type { ErrorHandler } from 'hono' import { StatusCode } from 'hono/utils/http-status' import httpStatus from 'http-status' import type { Toucan } from 'toucan-js' import { ZodError } from 'zod' import { Environment } from '../../bindings' import { ApiError } from '../utils/api-error' import { generateZodErrorMessage } from '../utils/zod' const genericJSONErrMsg = 'Unexpected end of JSON input' export const errorConverter = (err: unknown, sentry: Toucan): ApiError => { let error = err if (error instanceof ZodError) { const errorMessage = generateZodErrorMessage(error) error = new ApiError(httpStatus.BAD_REQUEST, errorMessage) } else if (error instanceof SyntaxError && error.message.includes(genericJSONErrMsg)) { throw new ApiError(httpStatus.BAD_REQUEST, 'Invalid JSON payload') } else if (!(error instanceof ApiError)) { const castedErr = (typeof error === 'object' ? error : {}) as Record const statusCode: StatusCode = typeof castedErr.statusCode === 'number' ? (castedErr.statusCode as StatusCode) : httpStatus.INTERNAL_SERVER_ERROR const message = (castedErr.description || castedErr.message || httpStatus[statusCode.toString() as keyof typeof httpStatus]) as string if (statusCode >= httpStatus.INTERNAL_SERVER_ERROR) { // Log any unhandled application error sentry.captureException(error) } error = new ApiError(statusCode, message, false) } return error as ApiError } export const errorHandler: ErrorHandler = async (err, c) => { // Can't load config in case error is inside config so load env here and default // to highest obscurity aka production if env is not set const env = c.env.ENV || 'production' const sentry = getSentry(c) const error = errorConverter(err, sentry) if (env === 'production' && !error.isOperational) { error.statusCode = httpStatus.INTERNAL_SERVER_ERROR error.message = httpStatus[httpStatus.INTERNAL_SERVER_ERROR].toString() } const response = { code: error.statusCode, message: error.message, ...(env === 'development' && { stack: err.stack }) } delete c.error // Don't pass to sentry middleware as it is either logged or already handled return c.json(response, error.statusCode as StatusCode) } ================================================ FILE: src/middlewares/rate-limiter.ts ================================================ import dayjs from 'dayjs' import { Context, MiddlewareHandler } from 'hono' import httpStatus from 'http-status' import { Environment } from '../../bindings' import { ApiError } from '../utils/api-error' const fakeDomain = 'http://rate-limiter.com/' const getRateLimitKey = (c: Context) => { const ip = c.req.raw.headers.get('cf-connecting-ip') const user = c.get('payload')?.sub const uniqueKey = user ? user : ip return uniqueKey } const getCacheKey = (endpoint: string, key: number | string, limit: number, interval: number) => { return `${fakeDomain}${endpoint}/${key}/${limit}/${interval}` } const setRateLimitHeaders = ( c: Context, secondsExpires: number, limit: number, remaining: number, interval: number ) => { c.header('X-RateLimit-Limit', limit.toString()) c.header('X-RateLimit-Remaining', remaining.toString()) c.header('X-RateLimit-Reset', secondsExpires.toString()) c.header('X-RateLimit-Policy', `${limit};w=${interval};comment="Sliding window"`) } export const rateLimit = (interval: number, limit: number): MiddlewareHandler => { return async (c, next) => { const key = getRateLimitKey(c) const endpoint = new URL(c.req.url).pathname const id = c.env.RATE_LIMITER.idFromName(key) const rateLimiter = c.env.RATE_LIMITER.get(id) const cache = await caches.open('rate-limiter') const cacheKey = getCacheKey(endpoint, key, limit, interval) const cached = await cache.match(cacheKey) let res: Response if (!cached) { res = await rateLimiter.fetch( new Request(fakeDomain, { method: 'POST', body: JSON.stringify({ scope: endpoint, key, limit, interval }) }) ) } else { res = cached } const clonedRes = res.clone() // eslint-disable-next-line no-console console.log() // This randomly fixes isolated storage errors const body = await clonedRes.json<{ blocked: boolean; remaining: number; expires: string }>() const secondsExpires = dayjs(body.expires).unix() - dayjs().unix() setRateLimitHeaders(c, secondsExpires, limit, body.remaining, interval) if (body.blocked) { if (!cached) { // Only cache blocked responses c.executionCtx.waitUntil(cache.put(cacheKey, res)) } throw new ApiError(httpStatus.TOO_MANY_REQUESTS, 'Too many requests') } await next() } } ================================================ FILE: src/models/base.model.ts ================================================ export abstract class BaseModel { abstract private_fields: string[] toJSON() { const properties = Object.getOwnPropertyNames(this) const publicProperties = properties.filter((property) => { return !this.private_fields.includes(property) && property !== 'private_fields' }) const json = publicProperties.reduce((obj: Record, key: string) => { obj[key] = this[key as keyof typeof this] return obj }, {}) return json } } ================================================ FILE: src/models/oauth/apple-user.model.ts ================================================ import { authProviders } from '../../config/authProviders' import { AppleUserType } from '../../types/oauth.types' import { OAuthUserModel } from './oauth-base.model' export class AppleUser extends OAuthUserModel { constructor(user: AppleUserType) { if (!user.email) { throw new Error('Apple account must have an email linked') } super({ _id: user.sub, providerType: authProviders.APPLE, _name: user.name, _email: user.email }) } } ================================================ FILE: src/models/oauth/discord-user.model.ts ================================================ import { authProviders } from '../../config/authProviders' import { DiscordUserType } from '../../types/oauth.types' import { OAuthUserModel } from './oauth-base.model' export class DiscordUser extends OAuthUserModel { constructor(user: DiscordUserType) { super({ providerType: authProviders.DISCORD, _name: user.username, _id: user.id, _email: user.email }) } } ================================================ FILE: src/models/oauth/facebook-user.model.ts ================================================ import { authProviders } from '../../config/authProviders' import { FacebookUserType } from '../../types/oauth.types' import { OAuthUserModel } from './oauth-base.model' export class FacebookUser extends OAuthUserModel { constructor(user: FacebookUserType) { super({ providerType: authProviders.FACEBOOK, _name: `${user.first_name} ${user.last_name}`, _id: user.id, _email: user.email }) } } ================================================ FILE: src/models/oauth/github-user.model.ts ================================================ import { authProviders } from '../../config/authProviders' import { GithubUserType } from '../../types/oauth.types' import { OAuthUserModel } from './oauth-base.model' export class GithubUser extends OAuthUserModel { constructor(user: GithubUserType) { super({ _id: user.id.toString(), providerType: authProviders.GITHUB, _name: user.name, _email: user.email }) } } ================================================ FILE: src/models/oauth/google-user.model.ts ================================================ import { authProviders } from '../../config/authProviders' import { GoogleUserType } from '../../types/oauth.types' import { OAuthUserModel } from './oauth-base.model' export class GoogleUser extends OAuthUserModel { constructor(user: GoogleUserType) { super({ providerType: authProviders.GOOGLE, _name: user.name, _id: user.id, _email: user.email }) } } ================================================ FILE: src/models/oauth/oauth-base.model.ts ================================================ import { AuthProviderType, OAuthUserType } from '../../types/oauth.types' import { BaseModel } from '../base.model' export class OAuthUserModel extends BaseModel implements OAuthUserType { _id: string _email: string _name?: string providerType: AuthProviderType private_fields = [] constructor(user: OAuthUserType) { super() this._id = `${user._id}` this._email = user._email this._name = user._name || undefined this.providerType = user.providerType } } ================================================ FILE: src/models/oauth/spotify-user.model.ts ================================================ import { authProviders } from '../../config/authProviders' import { SpotifyUserType } from '../../types/oauth.types' import { OAuthUserModel } from './oauth-base.model' export class SpotifyUser extends OAuthUserModel { constructor(user: SpotifyUserType) { super({ providerType: authProviders.SPOTIFY, _name: user.display_name, _id: user.id, _email: user.email }) } } ================================================ FILE: src/models/one-time-oauth-code.ts ================================================ import { Selectable } from 'kysely' import { OneTimeOauthCodeTable } from '../tables/one-time-oauth-code.table' import { BaseModel } from './base.model' export class OneTimeOauthCode extends BaseModel implements Selectable { code: string user_id: string access_token: string access_token_expires_at: Date refresh_token: string refresh_token_expires_at: Date expires_at: Date created_at: Date updated_at: Date private_fields = ['created_at', 'updated_at'] constructor(oneTimeCode: Selectable) { super() this.code = oneTimeCode.code this.user_id = oneTimeCode.user_id this.access_token = oneTimeCode.access_token this.access_token_expires_at = oneTimeCode.access_token_expires_at this.refresh_token = oneTimeCode.refresh_token this.refresh_token_expires_at = oneTimeCode.refresh_token_expires_at this.expires_at = oneTimeCode.expires_at this.created_at = oneTimeCode.created_at this.updated_at = oneTimeCode.updated_at } } ================================================ FILE: src/models/token.model.ts ================================================ export interface TokenResponse { access: { token: string expires: Date } refresh: { token: string expires: Date } } ================================================ FILE: src/models/user.model.ts ================================================ import bcrypt from 'bcryptjs' import { Selectable } from 'kysely' import { Role } from '../config/roles' import { UserTable } from '../tables/user.table' import { BaseModel } from './base.model' export class User extends BaseModel implements Selectable { id: string name: string | null email: string is_email_verified: boolean role: Role password: string | null private_fields = ['password', 'created_at', 'updated_at'] constructor(user: Selectable) { super() this.role = user.role this.id = user.id this.name = user.name || null this.email = user.email this.is_email_verified = user.is_email_verified this.role = user.role this.password = user.password } isPasswordMatch = async (userPassword: string): Promise => { if (!this.password) throw 'No password connected to user' return await bcrypt.compare(userPassword, this.password) } } ================================================ FILE: src/routes/auth.route.ts ================================================ import { Hono } from 'hono' import { Environment } from '../../bindings' import * as authController from '../controllers/auth/auth.controller' import * as appleController from '../controllers/auth/oauth/apple.controller' import * as discordController from '../controllers/auth/oauth/discord.controller' import * as facebookController from '../controllers/auth/oauth/facebook.controller' import * as githubController from '../controllers/auth/oauth/github.controller' import * as googleController from '../controllers/auth/oauth/google.controller' import * as oauthController from '../controllers/auth/oauth/oauth.controller' import * as spotifyController from '../controllers/auth/oauth/spotify.controller' import { auth } from '../middlewares/auth' import { rateLimit } from '../middlewares/rate-limiter' export const route = new Hono() const twoMinutes = 120 const oneRequest = 1 route.post('/register', authController.register) route.post('/login', authController.login) route.post('/refresh-tokens', authController.refreshTokens) route.post('/forgot-password', authController.forgotPassword) route.post('/reset-password', authController.resetPassword) route.post( '/send-verification-email', auth(), rateLimit(twoMinutes, oneRequest), authController.sendVerificationEmail ) route.post('/verify-email', authController.verifyEmail) route.get('/authorisations', auth(), authController.getAuthorisations) route.get('/github/redirect', githubController.githubRedirect) route.get('/google/redirect', googleController.googleRedirect) route.get('/spotify/redirect', spotifyController.spotifyRedirect) route.get('/discord/redirect', discordController.discordRedirect) route.get('/facebook/redirect', facebookController.facebookRedirect) route.get('/apple/redirect', appleController.appleRedirect) route.post('/github/callback', githubController.githubCallback) route.post('/spotify/callback', spotifyController.spotifyCallback) route.post('/discord/callback', discordController.discordCallback) route.post('/google/callback', googleController.googleCallback) route.post('/facebook/callback', facebookController.facebookCallback) route.post('/apple/callback', appleController.appleCallback) route.post('/github/:userId', auth('manageUsers'), githubController.linkGithub) route.post('/spotify/:userId', auth('manageUsers'), spotifyController.linkSpotify) route.post('/discord/:userId', auth('manageUsers'), discordController.linkDiscord) route.post('/google/:userId', auth('manageUsers'), googleController.linkGoogle) route.post('/facebook/:userId', auth('manageUsers'), facebookController.linkFacebook) route.post('/apple/:userId', auth('manageUsers'), appleController.linkApple) route.delete('/github/:userId', auth('manageUsers'), githubController.deleteGithubLink) route.delete('/spotify/:userId', auth('manageUsers'), spotifyController.deleteSpotifyLink) route.delete('/discord/:userId', auth('manageUsers'), discordController.deleteDiscordLink) route.delete('/google/:userId', auth('manageUsers'), googleController.deleteGoogleLink) route.delete('/facebook/:userId', auth('manageUsers'), facebookController.deleteFacebookLink) route.delete('/apple/:userId', auth('manageUsers'), appleController.deleteAppleLink) route.post('/validate', oauthController.validateOauthOneTimeCode) ================================================ FILE: src/routes/index.ts ================================================ import { route as authRoute } from './auth.route' import { route as userRoute } from './user.route' const base_path = 'v1' export const defaultRoutes = [ { path: `/${base_path}/auth`, route: authRoute }, { path: `/${base_path}/users`, route: userRoute } ] ================================================ FILE: src/routes/user.route.ts ================================================ import { Hono } from 'hono' import { Environment } from '../../bindings' import * as userController from '../controllers/user.controller' import { auth } from '../middlewares/auth' export const route = new Hono() route.post('/', auth('manageUsers'), userController.createUser) route.get('/', auth('getUsers'), userController.getUsers) route.get('/:userId', auth('getUsers'), userController.getUser) route.patch('/:userId', auth('manageUsers'), userController.updateUser) route.delete('/:userId', auth('manageUsers'), userController.deleteUser) ================================================ FILE: src/services/auth.service.ts ================================================ import httpStatus from 'http-status' import { Config } from '../config/config' import { getDBClient } from '../config/database' import { Role } from '../config/roles' import { tokenTypes } from '../config/tokens' import { OAuthUserModel } from '../models/oauth/oauth-base.model' import { TokenResponse } from '../models/token.model' import { User } from '../models/user.model' import { AuthProviderType } from '../types/oauth.types' import { ApiError } from '../utils/api-error' import { Register } from '../validations/auth.validation' import * as tokenService from './token.service' import * as userService from './user.service' import { createUser } from './user.service' export const loginUserWithEmailAndPassword = async ( email: string, password: string, databaseConfig: Config['database'] ): Promise => { const user = await userService.getUserByEmail(email, databaseConfig) // If password is null then the user must login with a social account if (user && !user.password) { throw new ApiError(httpStatus.UNAUTHORIZED, 'Please login with your social account') } if (!user || !(await user.isPasswordMatch(password))) { throw new ApiError(httpStatus.UNAUTHORIZED, 'Incorrect email or password') } return user } export const refreshAuth = async (refreshToken: string, config: Config): Promise => { try { const refreshTokenDoc = await tokenService.verifyToken( refreshToken, tokenTypes.REFRESH, config.jwt.secret ) if (!refreshTokenDoc.sub) { throw new Error() } const user = await userService.getUserById(refreshTokenDoc.sub, config.database) if (!user) { throw new Error() } return tokenService.generateAuthTokens(user, config.jwt) } catch { throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate') } } export const register = async ( body: Register, databaseConfig: Config['database'] ): Promise => { const registerBody = { ...body, role: 'user' as Role, is_email_verified: false } const newUser = await createUser(registerBody, databaseConfig) return newUser } export const resetPassword = async ( resetPasswordToken: string, newPassword: string, config: Config ): Promise => { try { const resetPasswordTokenDoc = await tokenService.verifyToken( resetPasswordToken, tokenTypes.RESET_PASSWORD, config.jwt.secret ) if (!resetPasswordTokenDoc.sub) { throw new Error() } const userId = resetPasswordTokenDoc.sub const user = await userService.getUserById(userId, config.database) if (!user) { throw new Error() } await userService.updateUserById(user.id, { password: newPassword }, config.database) } catch { throw new ApiError(httpStatus.UNAUTHORIZED, 'Password reset failed') } } export const verifyEmail = async (verifyEmailToken: string, config: Config): Promise => { try { const verifyEmailTokenDoc = await tokenService.verifyToken( verifyEmailToken, tokenTypes.VERIFY_EMAIL, config.jwt.secret ) if (!verifyEmailTokenDoc.sub) { throw new Error() } const userId = verifyEmailTokenDoc.sub const user = await userService.getUserById(userId, config.database) if (!user) { throw new Error() } await userService.updateUserById(user.id, { is_email_verified: true }, config.database) } catch { throw new ApiError(httpStatus.UNAUTHORIZED, 'Email verification failed') } } export const loginOrCreateUserWithOauth = async ( providerUser: OAuthUserModel, databaseConfig: Config['database'] ): Promise => { const user = await userService.getUserByProviderIdType( providerUser._id, providerUser.providerType, databaseConfig ) if (user) return user return userService.createOauthUser(providerUser, databaseConfig) } export const linkUserWithOauth = async ( userId: string, providerUser: OAuthUserModel, databaseConfig: Config['database'] ): Promise => { const db = getDBClient(databaseConfig) await db.transaction().execute(async (trx) => { try { await trx .selectFrom('user') .selectAll() .where('user.id', '=', userId) .executeTakeFirstOrThrow() } catch { throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate') } await trx .insertInto('authorisations') .values({ user_id: userId, provider_user_id: providerUser._id, provider_type: providerUser.providerType }) .executeTakeFirstOrThrow() }) } export const deleteOauthLink = async ( userId: string, provider: AuthProviderType, databaseConfig: Config['database'] ): Promise => { const db = getDBClient(databaseConfig) await db.transaction().execute(async (trx) => { const { count } = trx.fn let loginsNo: number try { const logins = await trx .selectFrom('user') .select('password') .select(count('authorisations.provider_user_id').as('authorisations')) .leftJoin('authorisations', 'authorisations.user_id', 'user.id') .where('user.id', '=', userId) .groupBy('user.password') .executeTakeFirstOrThrow() loginsNo = logins.password !== null ? logins.authorisations + 1 : logins.authorisations } catch { throw new ApiError(httpStatus.BAD_REQUEST, 'Account not linked') } const minLoginMethods = 1 if (loginsNo <= minLoginMethods) { throw new ApiError(httpStatus.BAD_REQUEST, 'Cannot unlink last login method') } const result = await trx .deleteFrom('authorisations') .where('user_id', '=', userId) .where('provider_type', '=', provider) .executeTakeFirst() if (!result.numDeletedRows || Number(result.numDeletedRows) < 1) { throw new ApiError(httpStatus.BAD_REQUEST, 'Account not linked') } }) } ================================================ FILE: src/services/email.service.ts ================================================ import { SESClient, SendEmailCommand, Message } from '@aws-sdk/client-ses' import { Config } from '../config/config' let client: SESClient export interface EmailData { name: string token: string } const getClient = (awsConfig: Config['aws']): SESClient => { client = client || new SESClient({ credentials: { accessKeyId: awsConfig.accessKeyId, secretAccessKey: awsConfig.secretAccessKey }, region: awsConfig.region }) return client } const sendEmail = async ( to: string, sender: string, message: Message, awsConfig: Config['aws'] ): Promise => { const sesClient = getClient(awsConfig) const command = new SendEmailCommand({ Destination: { ToAddresses: [to] }, Source: sender, Message: message }) await sesClient.send(command) } export const sendResetPasswordEmail = async ( email: string, emailData: EmailData, config: Config ): Promise => { const message = { Subject: { Data: 'Reset your password', Charset: 'UTF-8' }, Body: { Text: { Charset: 'UTF-8', Data: ` Hello ${emailData.name} Please reset your password by clicking the following link: ${emailData.token} ` } } } await sendEmail(email, config.email.sender, message, config.aws) } export const sendVerificationEmail = async ( email: string, emailData: EmailData, config: Config ): Promise => { const message = { Subject: { Data: 'Verify your email address', Charset: 'UTF-8' }, Body: { Text: { Charset: 'UTF-8', Data: ` Hello ${emailData.name} Please verify your email by clicking the following link: ${emailData.token} ` } } } await sendEmail(email, config.email.sender, message, config.aws) } ================================================ FILE: src/services/oauth/apple.service.ts ================================================ import httpStatus from 'http-status' import { ApiError } from '../../utils/api-error' type AppleResponse = { error?: string id_token?: string } export const getIdTokenFromCode = async ( code: string, clientId: string, clientSecret: string, redirectUrl: string ) => { const params = { grant_type: 'authorization_code', code, client_id: clientId, client_secret: clientSecret, redirect_uri: redirectUrl, response_mode: 'form_post' } const response = await fetch('https://appleid.apple.com/auth/token', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(params).toString() }) const result = (await response.json()) as AppleResponse if (result.error || !result.id_token) { throw new ApiError(httpStatus.UNAUTHORIZED, 'Unauthorized') } return result.id_token } ================================================ FILE: src/services/oauth/facebook.service.ts ================================================ import httpStatus from 'http-status' import * as queryString from 'query-string' import { ApiError } from '../../utils/api-error' type Options = { clientId: string redirectUrl: string scope?: string responseType?: string authType?: string display?: string state?: string } // TODO: remove when worker-auth-providers library fixed export const redirect = async (options: Options) => { const { clientId, redirectUrl, scope = 'email, user_friends', responseType = 'code', authType = 'rerequest', display = 'popup', state } = options if (!clientId) { throw new ApiError(httpStatus.BAD_REQUEST, 'Bad request') } const params = queryString.stringify({ client_id: clientId, redirect_uri: redirectUrl, scope, response_type: responseType, auth_type: authType, display, state }) const url = `https://www.facebook.com/v4.0/dialog/oauth?${params}` return url } ================================================ FILE: src/services/oauth/github.service.ts ================================================ import httpStatus from 'http-status' import * as queryString from 'query-string' import { ApiError } from '../../utils/api-error' const DEFAULT_SCOPE = ['read:user', 'user:email'] const DEFAULT_ALLOW_SIGNUP = true type Options = { clientId: string redirectTo?: string scope?: string[] allowSignup?: boolean state?: string } type Params = { client_id: string redirect_uri?: string scope: string allow_signup: boolean state?: string } // TODO: remove when worker-auth-providers library fixed export const redirect = async (options: Options) => { const { clientId, redirectTo, scope = DEFAULT_SCOPE, allowSignup = DEFAULT_ALLOW_SIGNUP, state } = options if (!clientId) { throw new ApiError(httpStatus.BAD_REQUEST, 'Bad request') } const params: Params = { client_id: clientId, scope: scope.join(' '), allow_signup: allowSignup, state } if (redirectTo) { params.redirect_uri = redirectTo } const paramString = queryString.stringify(params) const githubLoginUrl = `https://github.com/login/oauth/authorize?${paramString}` return githubLoginUrl } ================================================ FILE: src/services/oauth/spotify.service.ts ================================================ import httpStatus from 'http-status' import * as queryString from 'query-string' import { ApiError } from '../../utils/api-error' type Options = { clientId: string redirectUrl?: string scope?: string responseType?: string showDialog?: boolean state?: string } // TODO: remove when worker-auth-providers library fixed export const redirect = async (options: Options) => { const { clientId, redirectUrl, scope = 'user-library-read playlist-modify-private', responseType = 'code', showDialog = false, state } = options if (!clientId) { throw new ApiError(httpStatus.BAD_REQUEST, 'Bad request') } const params = queryString.stringify({ client_id: clientId, redirect_uri: redirectUrl, response_type: responseType, scope, show_dialog: showDialog, state }) const url = `https://accounts.spotify.com/authorize?${params}` return url } ================================================ FILE: src/services/token.service.ts ================================================ import jwt, { JwtPayload } from '@tsndr/cloudflare-worker-jwt' import dayjs, { Dayjs } from 'dayjs' import httpStatus from 'http-status' import { Selectable } from 'kysely' import { Config } from '../config/config' import { getDBClient } from '../config/database' import { Role } from '../config/roles' import { TokenType, tokenTypes } from '../config/tokens' import { OneTimeOauthCode } from '../models/one-time-oauth-code' import { TokenResponse } from '../models/token.model' import { User } from '../models/user.model' import { ApiError } from '../utils/api-error' import { generateId } from '../utils/utils' export const generateToken = async ( userId: string, type: TokenType, role: Role, expires: Dayjs, secret: string, isEmailVerified: boolean ) => { const payload = { sub: userId.toString(), exp: expires.unix(), iat: dayjs().unix(), type, role, isEmailVerified } return jwt.sign(payload, secret) } export const generateAuthTokens = async (user: Selectable, jwtConfig: Config['jwt']) => { const accessTokenExpires = dayjs().add(jwtConfig.accessExpirationMinutes, 'minutes') const accessToken = await generateToken( user.id, tokenTypes.ACCESS, user.role, accessTokenExpires, jwtConfig.secret, user.is_email_verified ) const refreshTokenExpires = dayjs().add(jwtConfig.refreshExpirationDays, 'days') const refreshToken = await generateToken( user.id, tokenTypes.REFRESH, user.role, refreshTokenExpires, jwtConfig.secret, user.is_email_verified ) return { access: { token: accessToken, expires: accessTokenExpires.toDate() }, refresh: { token: refreshToken, expires: refreshTokenExpires.toDate() } } } export const verifyToken = async (token: string, type: TokenType, secret: string) => { const isValid = await jwt.verify(token, secret) if (!isValid) { throw new Error('Token not valid') } const decoded = jwt.decode(token) const payload = decoded.payload as JwtPayload if (type !== payload.type) { throw new Error('Token not valid') } return payload } export const generateVerifyEmailToken = async ( user: Selectable, jwtConfig: Config['jwt'] ) => { const expires = dayjs().add(jwtConfig.verifyEmailExpirationMinutes, 'minutes') const verifyEmailToken = await generateToken( user.id, tokenTypes.VERIFY_EMAIL, user.role, expires, jwtConfig.secret, user.is_email_verified ) return verifyEmailToken } export const generateResetPasswordToken = async ( user: Selectable, jwtConfig: Config['jwt'] ) => { const expires = dayjs().add(jwtConfig.resetPasswordExpirationMinutes, 'minutes') const resetPasswordToken = await generateToken( user.id, tokenTypes.RESET_PASSWORD, user.role, expires, jwtConfig.secret, user.is_email_verified ) return resetPasswordToken } const generateOneTimeOauthCodeToken = () => { return generateId() } export const createOneTimeOauthCode = async ( userId: string, tokens: TokenResponse, config: Config ) => { const db = getDBClient(config.database) let attempts = 0 const maxAttempts = 5 let code = generateOneTimeOauthCodeToken() while (attempts < maxAttempts) { try { await db .insertInto('one_time_oauth_code') .values({ code, user_id: userId, access_token: tokens.access.token, access_token_expires_at: tokens.access.expires, refresh_token: tokens.refresh.token, refresh_token_expires_at: tokens.refresh.expires, expires_at: dayjs().add(config.jwt.accessExpirationMinutes, 'minutes').toDate() }) .executeTakeFirstOrThrow() break } catch { if (attempts >= maxAttempts) { throw new ApiError(httpStatus.INTERNAL_SERVER_ERROR, 'Failed to create one time code') } code = generateOneTimeOauthCodeToken() attempts++ } } return code } export const getOneTimeOauthCode = async (code: string, config: Config) => { const db = getDBClient(config.database) const oneTimeCode = await db.transaction().execute(async (trx) => { const oneTimeCode = await db .selectFrom('one_time_oauth_code') .selectAll() .where('code', '=', code) .where('expires_at', '>', dayjs().toDate()) .executeTakeFirst() if (!oneTimeCode) { throw new ApiError(httpStatus.BAD_REQUEST, 'Code invalid or expired') } await trx.deleteFrom('one_time_oauth_code').where('code', '=', code).execute() return oneTimeCode }) return new OneTimeOauthCode(oneTimeCode) } ================================================ FILE: src/services/user.service.ts ================================================ import httpStatus from 'http-status' import { UpdateResult } from 'kysely' import { Config } from '../config/config' import { getDBClient } from '../config/database' import { OAuthUserModel } from '../models/oauth/oauth-base.model' import { User } from '../models/user.model' import { UserTable } from '../tables/user.table' import { AuthProviderType } from '../types/oauth.types' import { ApiError } from '../utils/api-error' import { generateId } from '../utils/utils' import { CreateUser, UpdateUser } from '../validations/user.validation' interface getUsersFilter { email: string | undefined } interface getUsersOptions { sortBy: string limit: number page: number } export const createUser = async ( userBody: CreateUser, databaseConfig: Config['database'] ): Promise => { const db = getDBClient(databaseConfig) const id = generateId() try { await db .insertInto('user') .values({ ...userBody, id }) .executeTakeFirstOrThrow() } catch { throw new ApiError(httpStatus.BAD_REQUEST, 'User already exists') } const user = (await getUserById(id, databaseConfig)) as User return user } export const createOauthUser = async ( providerUser: OAuthUserModel, databaseConfig: Config['database'] ): Promise => { const db = getDBClient(databaseConfig) try { const id = generateId() await db.transaction().execute(async (trx) => { await trx .insertInto('user') .values({ id, name: providerUser._name, email: providerUser._email, is_email_verified: true, password: null, role: 'user' }) .executeTakeFirstOrThrow() await trx .insertInto('authorisations') .values({ user_id: id, provider_type: providerUser.providerType, provider_user_id: providerUser._id }) .executeTakeFirstOrThrow() }) } catch { throw new ApiError( httpStatus.FORBIDDEN, `Cannot signup with ${providerUser.providerType}, user already exists with that email` ) } const user = (await getUserByProviderIdType( providerUser._id, providerUser.providerType, databaseConfig )) as User return new User(user) } export const queryUsers = async ( filter: getUsersFilter, options: getUsersOptions, databaseConfig: Config['database'] ): Promise => { const db = getDBClient(databaseConfig) const [sortField, direction] = options.sortBy.split(':') as [keyof UserTable, 'asc' | 'desc'] let usersQuery = db .selectFrom('user') .selectAll() .orderBy(`user.${sortField}`, direction) .limit(options.limit) .offset(options.limit * options.page) if (filter.email) { usersQuery = usersQuery.where('user.email', '=', filter.email) } const users = await usersQuery.execute() return users.map((user) => new User(user)) } export const getUserById = async ( id: string, databaseConfig: Config['database'] ): Promise => { const db = getDBClient(databaseConfig) const user = await db.selectFrom('user').selectAll().where('user.id', '=', id).executeTakeFirst() return user ? new User(user) : undefined } export const getUserByEmail = async ( email: string, databaseConfig: Config['database'] ): Promise => { const db = getDBClient(databaseConfig) const user = await db .selectFrom('user') .selectAll() .where('user.email', '=', email) .executeTakeFirst() return user ? new User(user) : undefined } export const getUserByProviderIdType = async ( id: string, type: AuthProviderType, databaseConfig: Config['database'] ): Promise => { const db = getDBClient(databaseConfig) const user = await db .selectFrom('user') .innerJoin('authorisations', 'authorisations.user_id', 'user.id') .selectAll() .where('authorisations.provider_user_id', '=', id) .where('authorisations.provider_type', '=', type) .executeTakeFirst() return user ? new User(user) : undefined } export const updateUserById = async ( userId: string, updateBody: Partial, databaseConfig: Config['database'] ): Promise => { const db = getDBClient(databaseConfig) let result: UpdateResult try { result = await db .updateTable('user') .set(updateBody) .where('id', '=', userId) .executeTakeFirst() } catch { throw new ApiError(httpStatus.BAD_REQUEST, 'User already exists') } if (!result.numUpdatedRows || Number(result.numUpdatedRows) < 1) { throw new ApiError(httpStatus.NOT_FOUND, 'User not found') } const user = (await getUserById(userId, databaseConfig)) as User return user } export const deleteUserById = async ( userId: string, databaseConfig: Config['database'] ): Promise => { const db = getDBClient(databaseConfig) const result = await db.deleteFrom('user').where('user.id', '=', userId).executeTakeFirst() if (!result.numDeletedRows || Number(result.numDeletedRows) < 1) { throw new ApiError(httpStatus.NOT_FOUND, 'User not found') } } export const getAuthorisations = async (userId: string, databaseConfig: Config['database']) => { const db = getDBClient(databaseConfig) const auths = await db .selectFrom('user') .leftJoin('authorisations', 'authorisations.user_id', 'user.id') .selectAll() .where('user.id', '=', userId) .execute() if (!auths) { throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate') } const response = { local: auths[0].password !== null ? true : false, google: false, facebook: false, discord: false, spotify: false, github: false, apple: false } for (const auth of auths) { if (auth.provider_type === null) { continue } response[auth.provider_type as AuthProviderType] = true } return response } ================================================ FILE: src/tables/oauth.table.ts ================================================ export interface AuthProviderTable { provider_user_id: string provider_type: string user_id: string } ================================================ FILE: src/tables/one-time-oauth-code.table.ts ================================================ import { Generated } from 'kysely' export interface OneTimeOauthCodeTable { code: string user_id: string access_token: string access_token_expires_at: Date refresh_token: string refresh_token_expires_at: Date expires_at: Date created_at: Generated updated_at: Generated } ================================================ FILE: src/tables/user.table.ts ================================================ import { Role } from '../config/roles' export interface UserTable { id: string name: string | null // null if not available on oauth account linking email: string password: string | null // null if user is created via OAuth is_email_verified: boolean role: Role } ================================================ FILE: src/types/oauth.types.ts ================================================ import { authProviders } from '../config/authProviders' import { AppleUser } from '../models/oauth/apple-user.model' import { DiscordUser } from '../models/oauth/discord-user.model' import { FacebookUser } from '../models/oauth/facebook-user.model' import { GithubUser } from '../models/oauth/github-user.model' import { GoogleUser } from '../models/oauth/google-user.model' import { SpotifyUser } from '../models/oauth/spotify-user.model' export type AuthProviderType = (typeof authProviders)[keyof typeof authProviders] export interface OAuthUserType { _id: string _email: string _name?: string providerType: AuthProviderType } export interface AppleUserType { sub: string email?: string name?: string } export interface DiscordUserType { id: string email: string username: string } export interface FacebookUserType { id: string email: string first_name: string last_name: string } export interface GithubUserType { id: number email: string name: string } export interface GoogleUserType { id: string email: string name: string } export interface SpotifyUserType { id: string email: string display_name: string } export interface OauthUserTypes { facebook: FacebookUserType discord: DiscordUserType google: GoogleUserType spotify: SpotifyUserType apple: AppleUserType github: GithubUserType } export type ProviderUserMapping = { [key in AuthProviderType]: new ( user: OauthUserTypes[key] ) => FacebookUser | DiscordUser | GoogleUser | SpotifyUser | AppleUser | GithubUser } ================================================ FILE: src/utils/api-error.ts ================================================ export class ApiError extends Error { statusCode: number isOperational: boolean constructor(statusCode: number, message: string, isOperational = true) { super(message) this.statusCode = statusCode this.isOperational = isOperational } } ================================================ FILE: src/utils/utils.ts ================================================ import { nanoid } from 'nanoid' export const generateId = () => { return nanoid() } ================================================ FILE: src/utils/zod.ts ================================================ import { ZodError } from 'zod' import { fromError } from 'zod-validation-error' export const generateZodErrorMessage = (error: ZodError): string => { return fromError(error).message } ================================================ FILE: src/validations/auth.validation.ts ================================================ import { z } from 'zod' import { password } from './custom.refine.validation' import { hashPassword } from './custom.transform.validation' export const register = z.strictObject({ email: z.string().email(), password: z.string().superRefine(password).transform(hashPassword), name: z.string() }) export type Register = z.infer export const login = z.strictObject({ email: z.string(), password: z.string() }) export const refreshTokens = z.strictObject({ refresh_token: z.string() }) export const forgotPassword = z.strictObject({ email: z.string().email() }) export const resetPassword = z.strictObject({ query: z.object({ token: z.string() }), body: z.object({ password: z.string().superRefine(password).transform(hashPassword) }) }) export const verifyEmail = z.strictObject({ token: z.string() }) export const changePassword = z.strictObject({ oldPassword: z.string().superRefine(password).transform(hashPassword), newPassword: z.string().superRefine(password).transform(hashPassword) }) export const oauthCallback = z.object({ code: z.string(), platform: z.union([z.literal('web'), z.literal('ios'), z.literal('android')]) }) export const linkApple = z.object({ code: z.string() }) export const oauthRedirect = z.object({ state: z.string() }) export const validateOneTimeCode = z.object({ code: z.string() }) export const stateValidation = z.object({ platform: z.union([z.literal('web'), z.literal('ios'), z.literal('android')]) }) ================================================ FILE: src/validations/custom.refine.validation.ts ================================================ import { z } from 'zod' export const password = async (value: string, ctx: z.RefinementCtx): Promise => { if (value.length < 8) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'password must be at least 8 characters' }) return } if (!value.match(/\d/) || !value.match(/[a-zA-Z]/)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'password must contain at least 1 letter and 1 number' }) return } } ================================================ FILE: src/validations/custom.transform.validation.ts ================================================ import bcrypt from 'bcryptjs' export const hashPassword = async (value: string): Promise => { const hashedPassword = await bcrypt.hash(value, 8) return hashedPassword } ================================================ FILE: src/validations/custom.type.validation.ts ================================================ import { z } from 'zod' export const roleZodType = z.union([z.literal('admin'), z.literal('user')]) ================================================ FILE: src/validations/user.validation.ts ================================================ import { z } from 'zod' import { password } from './custom.refine.validation' import { hashPassword } from './custom.transform.validation' import { roleZodType } from './custom.type.validation' export const createUser = z.strictObject({ email: z.string().email(), password: z.string().superRefine(password).transform(hashPassword), name: z.string(), is_email_verified: z .any() .optional() .transform(() => false), role: roleZodType }) export type CreateUser = z.infer export const getUsers = z.object({ email: z.string().optional(), sort_by: z.string().optional().default('id:asc'), limit: z.coerce.number().optional().default(10), page: z.coerce.number().optional().default(0) }) export const getUser = z.object({ userId: z.string() }) export const updateUser = z.strictObject({ params: z.object({ userId: z.string() }), body: z .object({ email: z.string().email().optional(), name: z.string().optional(), role: z.union([z.literal('admin'), z.literal('user')]).optional() }) .refine(({ email, name, role }) => email || name || role, { message: 'At least one field is required' }) }) export type UpdateUser = | z.infer['body'] | { password: string } | { is_email_verified: boolean } export const deleteUser = z.strictObject({ userId: z.string() }) ================================================ FILE: tests/cloudflare-test.d.ts ================================================ declare module 'cloudflare:test' { import { Environment } from '../bindings' type ProvidedEnv = Environment } ================================================ FILE: tests/fixtures/authorisations.fixture.ts ================================================ import { faker } from '@faker-js/faker' import { Insertable } from 'kysely' import { authProviders } from '../../src/config/authProviders' import { Config } from '../../src/config/config' import { getDBClient } from '../../src/config/database' import { AuthProviderTable } from '../../src/tables/oauth.table' export const githubAuthorisation = (userId: string) => ({ provider_type: authProviders.GITHUB, provider_user_id: faker.number.int().toString(), user_id: userId }) export const discordAuthorisation = (userId: string) => ({ provider_type: authProviders.DISCORD, provider_user_id: faker.number.int().toString(), user_id: userId }) export const spotifyAuthorisation = (userId: string) => ({ provider_type: authProviders.SPOTIFY, provider_user_id: faker.number.int().toString(), user_id: userId }) export const googleAuthorisation = (userId: string) => ({ provider_type: authProviders.GOOGLE, provider_user_id: faker.number.int().toString(), user_id: userId }) export const facebookAuthorisation = (userId: string) => ({ provider_type: authProviders.FACEBOOK, provider_user_id: faker.number.int().toString(), user_id: userId }) export const appleAuthorisation = (userId: string) => ({ provider_type: authProviders.APPLE, provider_user_id: faker.number.int().toString(), user_id: userId }) export const insertAuthorisations = async ( authorisations: Insertable[], databaseConfig: Config['database'] ) => { const client = getDBClient(databaseConfig) for await (const authorisation of authorisations) { await client.insertInto('authorisations').values(authorisation).executeTakeFirst() } } ================================================ FILE: tests/fixtures/token.fixture.ts ================================================ import dayjs from 'dayjs' import { Config } from '../../src/config/config' import { Role } from '../../src/config/roles' import { tokenTypes, TokenType } from '../../src/config/tokens' import * as tokenService from '../../src/services/token.service' export interface TokenResponse { access: { token: string expires: string } refresh: { token: string expires: string } } export const getAccessToken = async ( userId: string, role: Role, jwtConfig: Config['jwt'], type: TokenType = tokenTypes.ACCESS, isEmailVerified = true ) => { const expires = dayjs().add(jwtConfig.accessExpirationMinutes, 'minutes') const token = await tokenService.generateToken( userId, type, role, expires, jwtConfig.secret, isEmailVerified ) return token } ================================================ FILE: tests/fixtures/user.fixture.ts ================================================ import { faker } from '@faker-js/faker' import bcrypt from 'bcryptjs' import { Insertable } from 'kysely' import { Config } from '../../src/config/config' import { getDBClient } from '../../src/config/database' import { UserTable } from '../../src/tables/user.table' import { generateId } from '../../src/utils/utils' const password = 'password1' const salt = bcrypt.genSaltSync(8) const hashedPassword = bcrypt.hashSync(password, salt) export type MockUser = Insertable export interface UserResponse { id: string name: string email: string role: string is_email_verified: boolean } export const userOne: MockUser = { id: generateId(), name: faker.person.fullName(), email: faker.internet.email().toLowerCase(), password, role: 'user', is_email_verified: false } export const userTwo: MockUser = { id: generateId(), name: faker.person.fullName(), email: faker.internet.email().toLowerCase(), password, role: 'user', is_email_verified: false } export const admin: MockUser = { id: generateId(), name: faker.person.fullName(), email: faker.internet.email().toLowerCase(), password, role: 'admin', is_email_verified: false } export const insertUsers = async (users: MockUser[], databaseConfig: Config['database']) => { const hashedUsers = users.map((user) => ({ ...user, password: user.password ? hashedPassword : null })) const client = getDBClient(databaseConfig) for await (const user of hashedUsers) { await client.insertInto('user').values(user).executeTakeFirst() } } ================================================ FILE: tests/integration/auth/auth.test.ts ================================================ // TODO: Add SES mock client back. It's not working with vitest // import { mockClient } from 'aws-sdk-client-mock' import { SESClient, SendEmailCommand } from '@aws-sdk/client-ses' import { faker } from '@faker-js/faker' import bcrypt from 'bcryptjs' import { env } from 'cloudflare:test' import dayjs from 'dayjs' import httpStatus from 'http-status' import { describe, expect, test, beforeEach } from 'vitest' import { getConfig } from '../../../src/config/config' import { getDBClient } from '../../../src/config/database' import { tokenTypes } from '../../../src/config/tokens' import * as tokenService from '../../../src/services/token.service' import { Register } from '../../../src/validations/auth.validation' import { appleAuthorisation, discordAuthorisation, facebookAuthorisation, githubAuthorisation, googleAuthorisation, insertAuthorisations, spotifyAuthorisation } from '../../fixtures/authorisations.fixture' import { getAccessToken, TokenResponse } from '../../fixtures/token.fixture' import { userOne, insertUsers, UserResponse, userTwo } from '../../fixtures/user.fixture' import { expectExtension, mockClient } from '../../mocks/awsClientStub' import { clearDBTables } from '../../utils/clear-db-tables' import { request } from '../../utils/test-request' const config = getConfig(env) const client = getDBClient(config.database) expect.extend(expectExtension) clearDBTables(['user', 'authorisations'], config.database) describe('Auth routes', () => { describe('POST /v1/auth/register', () => { let newUser: Register beforeEach(() => { newUser = { name: faker.person.fullName(), email: faker.internet.email().toLowerCase(), password: 'password1' } }) test('should return 201 and successfully register user if request data is ok', async () => { const res = await request('/v1/auth/register', { method: 'POST', body: JSON.stringify(newUser), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.CREATED) const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>() expect(body.user).not.toHaveProperty('password') expect(body.user).toEqual({ id: expect.anything(), name: newUser.name, email: newUser.email, role: 'user', is_email_verified: 0 }) const dbUser = await client .selectFrom('user') .selectAll() .where('user.id', '=', body.user.id) .executeTakeFirst() expect(dbUser).toBeDefined() if (!dbUser) return expect(dbUser.password).not.toBe(newUser.password) expect(dbUser).toMatchObject({ name: newUser.name, password: expect.anything(), email: newUser.email, is_email_verified: 0, role: 'user' }) expect(body.tokens).toEqual({ access: { token: expect.anything(), expires: expect.anything() }, refresh: { token: expect.anything(), expires: expect.anything() } }) }) test('should return 400 error if email is invalid', async () => { newUser.email = 'invalidEmail' const res = await request('/v1/auth/register', { method: 'POST', body: JSON.stringify(newUser), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 error if email is already used', async () => { await insertUsers([userOne], config.database) newUser.email = userOne.email const res = await request('/v1/auth/register', { method: 'POST', body: JSON.stringify(newUser), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 error if password length is less than 8 characters', async () => { newUser.password = 'passwo1' const res = await request('/v1/auth/register', { method: 'POST', body: JSON.stringify(newUser), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 error if role is set', async () => { const res = await request('/v1/auth/register', { method: 'POST', body: JSON.stringify({ ...newUser, role: 'admin' }), headers: { 'Content-Type': 'application/json' } }) const body = await res.json<{ code: number; message: string }>() expect(res.status).toBe(httpStatus.BAD_REQUEST) expect(body.message).toContain("Validation error: Unrecognized key(s) in object: 'role'") }) test('should return 400 error if is_email_verified is set', async () => { const res = await request('/v1/auth/register', { method: 'POST', body: JSON.stringify({ ...newUser, is_email_verified: true }), headers: { 'Content-Type': 'application/json' } }) const body = await res.json<{ code: number; message: string }>() expect(res.status).toBe(httpStatus.BAD_REQUEST) expect(body.message).toContain( "Validation error: Unrecognized key(s) in object: 'is_email_verified'" ) }) test('should return 400 if password does not contain both letters and numbers', async () => { newUser.password = 'password' const res = await request('/v1/auth/register', { method: 'POST', body: JSON.stringify(newUser), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) newUser.password = '11111111' const res2 = await request('/v1/auth/register', { method: 'POST', body: JSON.stringify(newUser), headers: { 'Content-Type': 'application/json' } }) expect(res2.status).toBe(httpStatus.BAD_REQUEST) }) }) describe('POST /v1/auth/login', () => { test('should return 200 and login user if email and password match', async () => { await insertUsers([userOne], config.database) const loginCredentials = { email: userOne.email, password: userOne.password } const res = await request('/v1/auth/login', { method: 'POST', body: JSON.stringify(loginCredentials), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.OK) const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>() expect(body.user).not.toHaveProperty('password') expect(body.user).toEqual({ id: expect.anything(), name: userOne.name, email: userOne.email, role: userOne.role, is_email_verified: 0 }) expect(body.tokens).toEqual({ access: { token: expect.anything(), expires: expect.anything() }, refresh: { token: expect.anything(), expires: expect.anything() } }) }) test('should return 401 error if there are no users with that email', async () => { const loginCredentials = { email: userOne.email, password: userOne.password } const res = await request('/v1/auth/login', { method: 'POST', body: JSON.stringify(loginCredentials), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) const body = await res.json() expect(body).toEqual({ code: httpStatus.UNAUTHORIZED, message: 'Incorrect email or password' }) }) test('should return 401 error if only oauth account exists', async () => { const newUser = { ...userOne, password: null } await insertUsers([newUser], config.database) const discordUser = discordAuthorisation(newUser.id) await insertAuthorisations([discordUser], config.database) const loginCredentials = { email: newUser.email, password: '' } const res = await request('/v1/auth/login', { method: 'POST', body: JSON.stringify(loginCredentials), headers: { 'Content-Type': 'application/json' } }) const body = await res.json() expect(res.status).toBe(httpStatus.UNAUTHORIZED) expect(body).toEqual({ code: httpStatus.UNAUTHORIZED, message: 'Please login with your social account' }) }) test('should return 401 error if password is wrong', async () => { await insertUsers([userOne], config.database) const loginCredentials = { email: userOne.email, password: 'wrongPassword1' } const res = await request('/v1/auth/login', { method: 'POST', body: JSON.stringify(loginCredentials), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) const body = await res.json() expect(body).toEqual({ code: httpStatus.UNAUTHORIZED, message: 'Incorrect email or password' }) }) }) describe('POST /v1/auth/refresh-tokens', () => { test('should return 200 and new auth tokens if refresh token is valid', async () => { await insertUsers([userOne], config.database) const expires = dayjs().add(config.jwt.refreshExpirationDays, 'days') const refreshToken = await tokenService.generateToken( userOne.id, tokenTypes.REFRESH, userOne.role, expires, config.jwt.secret, userOne.is_email_verified ) const res = await request('/v1/auth/refresh-tokens', { method: 'POST', body: JSON.stringify({ refresh_token: refreshToken }), headers: { 'Content-Type': 'application/json' } }) const body = await res.json<{ tokens: TokenResponse }>() expect(res.status).toBe(httpStatus.OK) expect(body).toEqual({ access: { token: expect.anything(), expires: expect.anything() }, refresh: { token: expect.anything(), expires: expect.anything() } }) }) test('should return 400 error if refresh token is missing from request body', async () => { const res = await request('/v1/auth/refresh-tokens', { method: 'POST', body: JSON.stringify({}), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 401 error if refresh token is signed using an invalid secret', async () => { await insertUsers([userOne], config.database) const expires = dayjs().add(config.jwt.refreshExpirationDays, 'days') const refreshToken = await tokenService.generateToken( userOne.id, tokenTypes.REFRESH, userOne.role, expires, 'random secret', userOne.is_email_verified ) const res = await request('/v1/auth/refresh-tokens', { method: 'POST', body: JSON.stringify({ refresh_token: refreshToken }), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) }) test('should return 401 error if refresh token is expired', async () => { await insertUsers([userOne], config.database) const expires = dayjs().subtract(1, 'minutes') const refreshToken = await tokenService.generateToken( userOne.id, tokenTypes.REFRESH, userOne.role, expires, config.jwt.secret, userOne.is_email_verified ) const res = await request('/v1/auth/refresh-tokens', { method: 'POST', body: JSON.stringify({ refresh_token: refreshToken }), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) }) test('should return 401 error if user is not found', async () => { const expires = dayjs().add(1, 'minutes') const refreshToken = await tokenService.generateToken( '123', tokenTypes.REFRESH, userOne.role, expires, config.jwt.secret, userOne.is_email_verified ) const res = await request('/v1/auth/refresh-tokens', { method: 'POST', body: JSON.stringify({ refresh_token: refreshToken }), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) }) }) describe('POST /v1/auth/forgot-password', () => { const sesMock = mockClient(SESClient) beforeEach(() => { sesMock.reset() }) test('should return 204 and send reset password email to the user', async () => { await insertUsers([userOne], config.database) sesMock.on(SendEmailCommand).resolves({ MessageId: 'message-id' }) const res = await request('/v1/auth/forgot-password', { method: 'POST', body: JSON.stringify({ email: userOne.email }), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.NO_CONTENT) expect(sesMock).toHaveReceivedCommandTimes(SendEmailCommand, 1) }) test('should return 204 and send email if only has oauth account', async () => { const newUser = { ...userOne, password: null } await insertUsers([newUser], config.database) const discordUser = discordAuthorisation(newUser.id) await insertAuthorisations([discordUser], config.database) sesMock.on(SendEmailCommand).resolves({ MessageId: 'message-id' }) const res = await request('/v1/auth/forgot-password', { method: 'POST', body: JSON.stringify({ email: newUser.email }), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.NO_CONTENT) expect(sesMock).toHaveReceivedCommandTimes(SendEmailCommand, 1) }) test('should return 400 if email is missing', async () => { await insertUsers([userOne], config.database) const res = await request('/v1/auth/forgot-password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 204 if email does not belong to any user', async () => { const res = await request('/v1/auth/forgot-password', { method: 'POST', body: JSON.stringify({ email: userOne.email }), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.NO_CONTENT) }) }) describe('POST /v1/auth/send-verification-email', () => { const sesMock = mockClient(SESClient) beforeEach(() => { sesMock.reset() }) test('should return 204 and send verification email to the user', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) sesMock.on(SendEmailCommand).resolves({ MessageId: 'message-id' }) const res = await request('/v1/auth/send-verification-email', { method: 'POST', body: JSON.stringify({ email: userOne.email }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.NO_CONTENT) expect(sesMock).toHaveReceivedCommandTimes(SendEmailCommand, 1) }) test('should return 204 and not send verification email if already verified', async () => { const newUser = { ...userOne } newUser.is_email_verified = true await insertUsers([newUser], config.database) const newUserAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt) sesMock.on(SendEmailCommand).resolves({ MessageId: 'message-id' }) const res = await request('/v1/auth/send-verification-email', { method: 'POST', body: JSON.stringify({ email: newUser.email }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${newUserAccessToken}` } }) expect(res.status).toBe(httpStatus.NO_CONTENT) expect(sesMock).toHaveReceivedCommandTimes(SendEmailCommand, 0) }) test('should return 429 if a second request is sent in under 2 minutes', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) sesMock.on(SendEmailCommand).resolves({ MessageId: 'message-id' }) const res = await request('/v1/auth/send-verification-email', { method: 'POST', body: JSON.stringify({ email: userOne.email }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.NO_CONTENT) const res2 = await request('/v1/auth/send-verification-email', { method: 'POST', body: JSON.stringify({ email: userOne.email }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res2.status).toBe(httpStatus.TOO_MANY_REQUESTS) expect(sesMock).toHaveReceivedCommandTimes(SendEmailCommand, 1) }) test('should return 401 error if access token is missing', async () => { await insertUsers([userOne], config.database) sesMock.on(SendEmailCommand).resolves({ MessageId: 'message-id' }) const res = await request('/v1/auth/send-verification-email', { method: 'POST', body: JSON.stringify({ email: userOne.email }), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) }) }) describe('POST /v1/auth/reset-password', () => { test('should return 204 and reset the password', async () => { await insertUsers([userOne], config.database) const newPassword = 'iamanewpassword123' const expires = dayjs().add(config.jwt.resetPasswordExpirationMinutes, 'minutes') const resetPasswordToken = await tokenService.generateToken( userOne.id, tokenTypes.RESET_PASSWORD, userOne.role, expires, config.jwt.secret, userOne.is_email_verified ) const res = await request(`/v1/auth/reset-password?token=${resetPasswordToken}`, { method: 'POST', body: JSON.stringify({ password: newPassword }), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.NO_CONTENT) const dbUser = await client .selectFrom('user') .selectAll() .where('user.id', '=', userOne.id) .executeTakeFirst() expect(dbUser).toBeDefined() if (!dbUser) return const isPasswordMatch = await bcrypt.compare(newPassword, dbUser.password || '') expect(isPasswordMatch).toBe(true) }) test('should return 400 if reset password token is missing', async () => { const res = await request('/v1/auth/reset-password', { method: 'POST', body: JSON.stringify({ password: 'iamanewpasword123' }), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 401 if reset password token is expired', async () => { await insertUsers([userOne], config.database) const newPassword = 'iamanewpassword123' const expires = dayjs().subtract(10, 'minutes') const resetPasswordToken = await tokenService.generateToken( userOne.id, tokenTypes.RESET_PASSWORD, userOne.role, expires, config.jwt.secret, userOne.is_email_verified ) const res = await request(`/v1/auth/reset-password?token=${resetPasswordToken}`, { method: 'POST', body: JSON.stringify({ password: newPassword }), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) }) test('should return 401 if user is not found', async () => { const newPassword = 'iamanewpassword123' const expires = dayjs().add(config.jwt.resetPasswordExpirationMinutes, 'minutes') const resetPasswordToken = await tokenService.generateToken( '123', tokenTypes.RESET_PASSWORD, userOne.role, expires, config.jwt.secret, userOne.is_email_verified ) const res = await request(`/v1/auth/reset-password?token=${resetPasswordToken}`, { method: 'POST', body: JSON.stringify({ password: newPassword }), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) }) test('should return 400 if password is missing or invalid', async () => { await insertUsers([userOne], config.database) const expires = dayjs().add(config.jwt.resetPasswordExpirationMinutes, 'minutes') const resetPasswordToken = await tokenService.generateToken( userOne.id, tokenTypes.RESET_PASSWORD, userOne.role, expires, config.jwt.secret, userOne.is_email_verified ) const res = await request(`/v1/auth/reset-password?token=${resetPasswordToken}`, { method: 'POST', body: JSON.stringify({}), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) const res2 = await request(`/v1/auth/reset-password?token=${resetPasswordToken}`, { method: 'POST', body: JSON.stringify({ password: 'short1' }), headers: { 'Content-Type': 'application/json' } }) expect(res2.status).toBe(httpStatus.BAD_REQUEST) const res3 = await request(`/v1/auth/reset-password?token=${resetPasswordToken}`, { method: 'POST', body: JSON.stringify({ password: 'password' }), headers: { 'Content-Type': 'application/json' } }) expect(res3.status).toBe(httpStatus.BAD_REQUEST) const res4 = await request(`/v1/auth/reset-password?token=${resetPasswordToken}`, { method: 'POST', body: JSON.stringify({ password: '11111111' }), headers: { 'Content-Type': 'application/json' } }) expect(res4.status).toBe(httpStatus.BAD_REQUEST) }) }) describe('POST /v1/auth/verify-email', () => { test('should return 204 and verify the email', async () => { await insertUsers([userOne], config.database) const expires = dayjs().add(config.jwt.verifyEmailExpirationMinutes, 'minutes') const verifyEmailToken = await tokenService.generateToken( userOne.id, tokenTypes.VERIFY_EMAIL, userOne.role, expires, config.jwt.secret, userOne.is_email_verified ) const res = await request(`/v1/auth/verify-email?token=${verifyEmailToken}`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.NO_CONTENT) const dbUser = await client .selectFrom('user') .selectAll() .where('user.id', '=', userOne.id) .executeTakeFirst() expect(dbUser).toBeDefined() if (!dbUser) return expect(dbUser.is_email_verified).toBe(1) }) test('should return 400 if verify email token is missing', async () => { const res = await request('/v1/auth/verify-email', { method: 'POST', headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 401 if verify email token is expired', async () => { await insertUsers([userOne], config.database) const expires = dayjs().subtract(10, 'minutes') const verifyEmailToken = await tokenService.generateToken( userOne.id, tokenTypes.VERIFY_EMAIL, userOne.role, expires, config.jwt.secret, userOne.is_email_verified ) const res = await request(`/v1/auth/verify-email?token=${verifyEmailToken}`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) }) test('should return 401 if verify email token is an access token', async () => { await insertUsers([userOne], config.database) const expires = dayjs().add(10, 'minutes') const verifyEmailToken = await tokenService.generateToken( userOne.id, tokenTypes.ACCESS, userOne.role, expires, config.jwt.secret, userOne.is_email_verified ) const res = await request(`/v1/auth/verify-email?token=${verifyEmailToken}`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) }) test('should return 401 if user is not found', async () => { const expires = dayjs().add(config.jwt.verifyEmailExpirationMinutes, 'minutes') const verifyEmailToken = await tokenService.generateToken( '123', tokenTypes.VERIFY_EMAIL, userOne.role, expires, config.jwt.secret, userOne.is_email_verified ) const res = await request(`/v1/auth/verify-email?token=${verifyEmailToken}`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) }) }) describe('GET /v1/auth/authorisations', () => { test('should 200 and list of user authentication methods with local true', async () => { await insertUsers([userOne], config.database) const accessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const res = await request('/v1/auth/authorisations', { method: 'GET', headers: { Authorization: `Bearer ${accessToken}` } }) const body = await res.json() expect(res.status).toBe(httpStatus.OK) expect(body).toEqual({ local: true, facebook: false, github: false, google: false, spotify: false, discord: false, apple: false }) }) test('should 200 and list of user authentication methods with discord true', async () => { const user = { ...userOne } user.password = null await insertUsers([user], config.database) const discordAuth = discordAuthorisation(user.id) await insertAuthorisations([discordAuth], config.database) const accessToken = await getAccessToken(user.id, user.role, config.jwt) const res = await request('/v1/auth/authorisations', { method: 'GET', headers: { Authorization: `Bearer ${accessToken}` } }) const body = await res.json() expect(res.status).toBe(httpStatus.OK) expect(body).toEqual({ local: false, facebook: false, github: false, google: false, spotify: false, discord: true, apple: false }) }) test('should 200 and list of user authentication methods with all true', async () => { await insertUsers([userOne], config.database) const discordAuth = discordAuthorisation(userOne.id) const spotifyAuth = spotifyAuthorisation(userOne.id) const googleAuth = googleAuthorisation(userOne.id) const githubAuth = githubAuthorisation(userOne.id) const facebookAuth = facebookAuthorisation(userOne.id) const appleAuth = appleAuthorisation(userOne.id) await insertAuthorisations( [discordAuth, spotifyAuth, googleAuth, facebookAuth, githubAuth, appleAuth], config.database ) const accessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const res = await request('/v1/auth/authorisations', { method: 'GET', headers: { Authorization: `Bearer ${accessToken}` } }) const body = await res.json() expect(res.status).toBe(httpStatus.OK) expect(body).toEqual({ local: true, facebook: true, github: true, google: true, spotify: true, discord: true, apple: true }) }) test('should return 403 if user has not verified their email', async () => { await insertUsers([userTwo], config.database) const accessToken = await getAccessToken( userTwo.id, userTwo.role, config.jwt, tokenTypes.ACCESS, userTwo.is_email_verified ) const res = await request('/v1/auth/authorisations', { method: 'GET', headers: { Authorization: `Bearer ${accessToken}` } }) expect(res.status).toBe(httpStatus.FORBIDDEN) }) }) describe('Auth middleware', () => { test('should return 401 if auth header is malformed', async () => { const res = await request('/v1/users/123', { method: 'GET', headers: { Authorization: 'Bearer123' } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) }) }) }) ================================================ FILE: tests/integration/auth/oauth/apple.test.ts ================================================ import { faker } from '@faker-js/faker' import jwt from '@tsndr/cloudflare-worker-jwt' import { env, fetchMock } from 'cloudflare:test' import httpStatus from 'http-status' import { describe, expect, test, beforeAll, afterEach } from 'vitest' import { authProviders } from '../../../../src/config/authProviders' import { getConfig } from '../../../../src/config/config' import { getDBClient } from '../../../../src/config/database' import { tokenTypes } from '../../../../src/config/tokens' import { AppleUserType } from '../../../../src/types/oauth.types' import { appleAuthorisation, githubAuthorisation, googleAuthorisation, insertAuthorisations } from '../../../fixtures/authorisations.fixture' import { getAccessToken } from '../../../fixtures/token.fixture' import { userOne, insertUsers, userTwo } from '../../../fixtures/user.fixture' import { clearDBTables } from '../../../utils/clear-db-tables' import { request } from '../../../utils/test-request' const config = getConfig(env) const client = getDBClient(config.database) clearDBTables(['authorisations', 'user'], config.database) describe('Oauth Apple routes', () => { describe('GET /v1/auth/apple/redirect', () => { test('should return 302 and successfully redirect to apple', async () => { const state = btoa(JSON.stringify({ platform: 'web' })) const urlEncodedRedirectUrl = encodeURIComponent(config.oauth.provider.apple.redirectUrl) const res = await request(`/v1/auth/apple/redirect?state=${state}`, { method: 'GET' }) expect(res.status).toBe(httpStatus.FOUND) expect(res.headers.get('location')).toContain( 'https://appleid.apple.com/auth/authorize?client_id=myclientid&redirect_uri=' + `${urlEncodedRedirectUrl}&response_mode=form_post&response_type=code&scope=email` + `&state=${state}` ) }) test('should return 400 error if state is not provided', async () => { const res = await request('/v1/auth/apple/redirect', { method: 'GET' }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 error if state platform is not provided', async () => { const state = btoa(JSON.stringify({})) const res = await request(`/v1/auth/apple/redirect?state=${state}`, { method: 'GET' }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 error if state platform is invalid', async () => { const state = btoa(JSON.stringify({ platform: 'fake' })) const res = await request(`/v1/auth/apple/redirect?state=${state}`, { method: 'GET' }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) }) describe('POST /v1/auth/apple/callback', () => { let newUser: AppleUserType let state: string beforeAll(async () => { newUser = { sub: faker.number.int().toString(), name: faker.person.fullName(), email: faker.internet.email() } fetchMock.activate() state = btoa(JSON.stringify({ platform: 'web' })) }) afterEach(() => fetchMock.assertNoPendingInterceptors()) test('should return 200 and successfully register + redirect with one time code', async () => { const mockJWT = await jwt.sign(newUser, 'randomSecret') const appleMock = fetchMock.get('https://appleid.apple.com') appleMock .intercept({ method: 'POST', path: '/auth/token' }) .reply(200, JSON.stringify({ id_token: mockJWT })) const providerId = '123456' const formData = new FormData() formData.append('code', providerId) formData.append('state', state) const res = await request('/v1/auth/apple/callback', { method: 'POST', body: formData }) expect(res.status).toBe(httpStatus.FOUND) expect(res.headers.get('location')).toContain( `${config.oauth.platform.ios.redirectUrl}?oneTimeCode=` ) const location = res.headers.get('location') const oneTimeCode = location?.split('=')[1].split('&')[0] expect(oneTimeCode).toBeDefined() if (!oneTimeCode) return const returnedState = location?.split('=')[2] expect(returnedState).toBe(state) const dbOneTimeCode = await client .selectFrom('one_time_oauth_code') .selectAll() .where('one_time_oauth_code.code', '=', oneTimeCode) .executeTakeFirst() expect(dbOneTimeCode).toBeDefined() if (!dbOneTimeCode) return expect(dbOneTimeCode).toEqual({ code: oneTimeCode, user_id: expect.any(String), access_token: expect.any(String), access_token_expires_at: expect.any(Date), refresh_token: expect.any(String), refresh_token_expires_at: expect.any(Date), expires_at: expect.any(Date), created_at: expect.any(Date), updated_at: expect.any(Date) }) const dbUser = await client .selectFrom('user') .selectAll() .where('user.id', '=', dbOneTimeCode.user_id) .executeTakeFirst() expect(dbUser).toBeDefined() if (!dbUser) return expect(dbUser.password).toBeNull() expect(dbUser).toMatchObject({ name: newUser.name, password: null, email: newUser.email, role: 'user', is_email_verified: 1 }) const oauthUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.APPLE) .where('authorisations.user_id', '=', dbOneTimeCode.user_id) .where('authorisations.provider_user_id', '=', newUser.sub) .executeTakeFirst() expect(oauthUser).toBeDefined() if (!oauthUser) return }) test('should redirect and successfully login user if already created', async () => { await insertUsers([userOne], config.database) const appleUser = appleAuthorisation(userOne.id) await insertAuthorisations([appleUser], config.database) newUser.sub = appleUser.provider_user_id const mockJWT = await jwt.sign(newUser, 'randomSecret') const appleMock = fetchMock.get('https://appleid.apple.com') appleMock .intercept({ method: 'POST', path: '/auth/token' }) .reply(200, JSON.stringify({ id_token: mockJWT })) const providerId = '123456' const formData = new FormData() formData.append('code', providerId) formData.append('state', state) const res = await request('/v1/auth/apple/callback', { method: 'POST', body: formData }) expect(res.status).toBe(httpStatus.FOUND) expect(res.headers.get('location')).toContain( `${config.oauth.platform.ios.redirectUrl}?oneTimeCode=` ) expect(res.headers.get('location')).toContain(`state=${state}`) }) test('should redirect with error if user exists but has not linked their apple', async () => { await insertUsers([userOne], config.database) newUser.email = userOne.email const mockJWT = await jwt.sign(newUser, 'randomSecret') const appleMock = fetchMock.get('https://appleid.apple.com') appleMock .intercept({ method: 'POST', path: '/auth/token' }) .reply(200, JSON.stringify({ id_token: mockJWT })) const providerId = '123456' const formData = new FormData() formData.append('code', providerId) formData.append('state', state) const res = await request('/v1/auth/apple/callback', { method: 'POST', body: formData }) expect(res.status).toBe(httpStatus.FOUND) const encodedError = 'Cannot%20signup%20with%20apple,' + '%20user%20already%20exists%20with%20that%20email' expect(res.headers.get('location')).toBe( `${config.oauth.platform.ios.redirectUrl}?error=${encodedError}&state=${state}` ) }) //TODO: return custom error message for this scenario test('should redirect with error if no apple email is provided', async () => { delete newUser.email const mockJWT = await jwt.sign(newUser, 'randomSecret') const appleMock = fetchMock.get('https://appleid.apple.com') appleMock .intercept({ method: 'POST', path: '/auth/token' }) .reply(200, JSON.stringify({ id_token: mockJWT })) const providerId = '123456' const formData = new FormData() formData.append('state', state) formData.append('code', providerId) const res = await request('/v1/auth/apple/callback', { method: 'POST', body: formData }) expect(res.status).toBe(httpStatus.FOUND) expect(res.headers.get('location')).toBe( `${config.oauth.platform.ios.redirectUrl}?error=Unauthorized&state=${state}` ) }) test('should redirect with error if code is invalid', async () => { const appleMock = fetchMock.get('https://appleid.apple.com') appleMock .intercept({ method: 'POST', path: '/auth/token' }) .reply(httpStatus.UNAUTHORIZED, JSON.stringify({ error: 'error' })) const providerId = '123456' const formData = new FormData() formData.append('code', providerId) formData.append('state', state) const res = await request('/v1/auth/apple/callback', { method: 'POST', body: formData }) expect(res.status).toBe(httpStatus.FOUND) expect(res.headers.get('location')).toBe( `${config.oauth.platform.ios.redirectUrl}?error=Unauthorized&state=${state}` ) }) test('should redirect with error if no code provided', async () => { const formData = new FormData() formData.append('state', state) const res = await request('/v1/auth/apple/callback', { method: 'POST', body: formData }) expect(res.status).toBe(httpStatus.FOUND) expect(res.headers.get('location')).toBe( `${config.oauth.platform.ios.redirectUrl}?error=Bad%20request&state=${state}` ) }) test('should return 400 error if state is not provided', async () => { const providerId = '123456' const formData = new FormData() formData.append('code', providerId) const res = await request('/v1/auth/apple/callback', { method: 'POST', body: formData }) expect(res.status).toBe(httpStatus.FOUND) expect(res.headers.get('location')).toBe( `${config.oauth.platform.web.redirectUrl}?error=Something%20went%20wrong` ) }) test('should return 400 error if platform is not provided', async () => { const providerId = '123456' const formData = new FormData() state = btoa(JSON.stringify({})) formData.append('code', providerId) formData.append('state', state) const res = await request('/v1/auth/apple/callback', { method: 'POST', body: formData }) expect(res.status).toBe(httpStatus.FOUND) expect(res.headers.get('location')).toBe( `${config.oauth.platform.web.redirectUrl}?error=Bad%20request&state=${state}` ) }) test('should return 400 error if platform is invalid', async () => { const providerId = '123456' const formData = new FormData() state = btoa(JSON.stringify({ platform: 'wb' })) formData.append('code', providerId) formData.append('state', state) const res = await request('/v1/auth/apple/callback', { method: 'POST', body: formData }) expect(res.status).toBe(httpStatus.FOUND) expect(res.headers.get('location')).toBe( `${config.oauth.platform.web.redirectUrl}?error=Bad%20request&state=${state}` ) }) }) describe('POST /v1/auth/apple/:userId', () => { let newUser: AppleUserType beforeAll(async () => { newUser = { sub: faker.number.int().toString(), name: faker.person.fullName(), email: faker.internet.email() } fetchMock.activate() }) afterEach(() => fetchMock.assertNoPendingInterceptors()) test('should return 200 and successfully link apple account', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const mockJWT = await jwt.sign(newUser, 'randomSecret') const appleMock = fetchMock.get('https://appleid.apple.com') appleMock .intercept({ method: 'POST', path: '/auth/token' }) .reply(200, JSON.stringify({ id_token: mockJWT })) const providerId = '123456' const res = await request(`/v1/auth/apple/${userOne.id}`, { method: 'POST', body: JSON.stringify({ code: providerId }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.NO_CONTENT) const dbUser = await client .selectFrom('user') .selectAll() .where('user.id', '=', userOne.id) .executeTakeFirst() expect(dbUser).toBeDefined() if (!dbUser) return expect(dbUser.password).toBeDefined() expect(dbUser).toMatchObject({ name: userOne.name, password: expect.anything(), email: userOne.email, role: userOne.role, is_email_verified: 0 }) const oauthUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.APPLE) .where('authorisations.user_id', '=', userOne.id) .where('authorisations.provider_user_id', '=', newUser.sub) .executeTakeFirst() expect(oauthUser).toBeDefined() if (!oauthUser) return }) test('should return 401 if user does not exist when linking', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) await client.deleteFrom('user').where('user.id', '=', userOne.id).execute() const mockJWT = await jwt.sign(newUser, 'randomSecret') const appleMock = fetchMock.get('https://appleid.apple.com') appleMock .intercept({ method: 'POST', path: '/auth/token' }) .reply(200, JSON.stringify({ id_token: mockJWT })) const providerId = '123456' const res = await request(`/v1/auth/apple/${userOne.id}`, { method: 'POST', body: JSON.stringify({ code: providerId }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) const oauthUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.APPLE) .where('authorisations.user_id', '=', userOne.id) .where('authorisations.provider_user_id', '=', String(newUser.sub)) .executeTakeFirst() expect(oauthUser).toBeUndefined() }) test('should return 401 if code is invalid', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const appleMock = fetchMock.get('https://appleid.apple.com') appleMock .intercept({ method: 'POST', path: '/auth/token' }) .reply(httpStatus.UNAUTHORIZED, JSON.stringify({ error: 'error' })) const providerId = '123456' const res = await request(`/v1/auth/apple/${userOne.id}`, { method: 'POST', body: JSON.stringify({ code: providerId }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) }) test('should return 403 if linking different user', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const providerId = '123456' const res = await request('/v1/auth/apple/5298', { method: 'POST', body: JSON.stringify({ code: providerId }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.FORBIDDEN) }) test('should return 400 if no code provided', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const res = await request(`/v1/auth/apple/${userOne.id}`, { method: 'POST', body: JSON.stringify({}), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 401 error if access token is missing', async () => { const res = await request('/v1/auth/apple/1234', { method: 'POST', body: JSON.stringify({}), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) }) test('should return 403 if user has not verified their email', async () => { await insertUsers([userTwo], config.database) const accessToken = await getAccessToken( userTwo.id, userTwo.role, config.jwt, tokenTypes.ACCESS, userTwo.is_email_verified ) const res = await request('/v1/auth/apple/5298', { method: 'POST', body: JSON.stringify({}), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` } }) expect(res.status).toBe(httpStatus.FORBIDDEN) }) }) describe('DELETE /v1/auth/apple/:userId', () => { test('should return 200 and successfully remove apple account link', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const appleUser = appleAuthorisation(userOne.id) await insertAuthorisations([appleUser], config.database) const res = await request(`/v1/auth/apple/${userOne.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.NO_CONTENT) const oauthUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.APPLE) .where('authorisations.user_id', '=', userOne.id) .executeTakeFirst() expect(oauthUser).toBeUndefined() if (!oauthUser) return }) test('should return 400 if user does not have a local login and only 1 link', async () => { const newUser = { ...userOne, password: null } await insertUsers([newUser], config.database) const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt) const appleUser = appleAuthorisation(newUser.id) await insertAuthorisations([appleUser], config.database) const res = await request(`/v1/auth/apple/${newUser.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) const oauthUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.APPLE) .where('authorisations.user_id', '=', newUser.id) .executeTakeFirst() expect(oauthUser).toBeDefined() }) test('should return 400 if user does not have apple link', async () => { const newUser = { ...userOne, password: null } await insertUsers([newUser], config.database) const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt) const githubUser = githubAuthorisation(newUser.id) await insertAuthorisations([githubUser], config.database) const googleUser = googleAuthorisation(newUser.id) await insertAuthorisations([googleUser], config.database) const res = await request(`/v1/auth/apple/${newUser.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 if user only has a local login', async () => { const newUser = { ...userOne, password: null } await insertUsers([newUser], config.database) const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt) const res = await request(`/v1/auth/apple/${newUser.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 200 if user does not have a local login and 2 links', async () => { const newUser = { ...userOne, password: null } await insertUsers([newUser], config.database) const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt) const appleUser = appleAuthorisation(newUser.id) const githubUser = githubAuthorisation(newUser.id) await insertAuthorisations([appleUser, githubUser], config.database) const res = await request(`/v1/auth/apple/${newUser.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.NO_CONTENT) const oauthAppleUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.APPLE) .where('authorisations.user_id', '=', newUser.id) .executeTakeFirst() expect(oauthAppleUser).toBeUndefined() const oauthFacebookUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.GITHUB) .where('authorisations.user_id', '=', newUser.id) .executeTakeFirst() expect(oauthFacebookUser).toBeDefined() }) test('should return 403 if unlinking different user', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const res = await request('/v1/auth/apple/5298', { method: 'DELETE', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.FORBIDDEN) }) test('should return 401 error if access token is missing', async () => { const res = await request('/v1/auth/apple/1234', { method: 'DELETE', headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) }) test('should return 403 if user has not verified their email', async () => { await insertUsers([userTwo], config.database) const accessToken = await getAccessToken( userTwo.id, userTwo.role, config.jwt, tokenTypes.ACCESS, userTwo.is_email_verified ) const res = await request('/v1/auth/apple/5298', { method: 'DELETE', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` } }) expect(res.status).toBe(httpStatus.FORBIDDEN) }) }) }) ================================================ FILE: tests/integration/auth/oauth/discord.test.ts ================================================ import { faker } from '@faker-js/faker' import { env, fetchMock } from 'cloudflare:test' import httpStatus from 'http-status' import { describe, expect, test, beforeAll, afterEach } from 'vitest' import { authProviders } from '../../../../src/config/authProviders' import { getConfig } from '../../../../src/config/config' import { getDBClient } from '../../../../src/config/database' import { tokenTypes } from '../../../../src/config/tokens' import { DiscordUserType } from '../../../../src/types/oauth.types' import { discordAuthorisation, facebookAuthorisation, githubAuthorisation, insertAuthorisations } from '../../../fixtures/authorisations.fixture' import { getAccessToken, TokenResponse } from '../../../fixtures/token.fixture' import { userOne, insertUsers, UserResponse, userTwo } from '../../../fixtures/user.fixture' import { clearDBTables } from '../../../utils/clear-db-tables' import { request } from '../../../utils/test-request' const config = getConfig(env) const client = getDBClient(config.database) clearDBTables(['user', 'authorisations'], config.database) describe('Oauth Discord routes', () => { describe('GET /v1/auth/discord/redirect', () => { test('should return 302 and successfully redirect to discord', async () => { const state = btoa(JSON.stringify({ platform: 'web' })) const urlEncodedRedirectUrl = encodeURIComponent(config.oauth.platform.web.redirectUrl) const res = await request(`/v1/auth/discord/redirect?state=${state}`, { method: 'GET' }) expect(res.status).toBe(httpStatus.FOUND) expect(res.headers.get('location')).toBe( 'https://discord.com/api/oauth2/authorize?client_id=' + `${config.oauth.provider.discord.clientId}&redirect_uri=${urlEncodedRedirectUrl}&` + `response_type=code&scope=identify%20email&state=${state}` ) }) test('should return 400 error if state is not provided', async () => { const res = await request('/v1/auth/discord/redirect', { method: 'GET' }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 error if state platform is not provided', async () => { const state = btoa(JSON.stringify({})) const res = await request(`/v1/auth/discord/redirect?state=${state}`, { method: 'GET' }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 error if state platform is invalid', async () => { const state = btoa(JSON.stringify({ platform: 'fake' })) const res = await request(`/v1/auth/discord/redirect?state=${state}`, { method: 'GET' }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) }) describe('POST /v1/auth/discord/callback', () => { let newUser: DiscordUserType beforeAll(async () => { newUser = { id: faker.number.int().toString(), username: faker.person.fullName(), email: faker.internet.email() } fetchMock.activate() }) afterEach(() => fetchMock.assertNoPendingInterceptors()) test('should return 200 and successfully register user if request data is ok', async () => { const discordApiMock = fetchMock.get('https://discord.com') discordApiMock .intercept({ method: 'GET', path: '/api/users/@me' }) .reply(200, JSON.stringify(newUser)) const discordMock = fetchMock.get('https://discordapp.com') discordMock .intercept({ method: 'POST', path: '/api/oauth2/token' }) .reply(200, JSON.stringify({ access_token: '1234' })) const providerId = '123456' const res = await request('/v1/auth/discord/callback', { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json' } }) const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>() expect(res.status).toBe(httpStatus.OK) expect(body.user).not.toHaveProperty('password') expect(body.user).toEqual({ id: expect.anything(), name: newUser.username, email: newUser.email, role: 'user', is_email_verified: 1 }) const dbUser = await client .selectFrom('user') .selectAll() .where('user.id', '=', body.user.id) .executeTakeFirst() expect(dbUser).toBeDefined() if (!dbUser) return expect(dbUser.password).toBeNull() expect(dbUser).toMatchObject({ name: newUser.username, password: null, email: newUser.email, role: 'user', is_email_verified: 1 }) const oauthUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.DISCORD) .where('authorisations.user_id', '=', body.user.id) .where('authorisations.provider_user_id', '=', newUser.id) .executeTakeFirst() expect(oauthUser).toBeDefined() if (!oauthUser) return expect(body.tokens).toEqual({ access: { token: expect.anything(), expires: expect.anything() }, refresh: { token: expect.anything(), expires: expect.anything() } }) }) test('should return 200 and successfully login user if already created', async () => { await insertUsers([userOne], config.database) const discordUser = discordAuthorisation(userOne.id) await insertAuthorisations([discordUser], config.database) newUser.id = discordUser.provider_user_id const discordApiMock = fetchMock.get('https://discord.com') discordApiMock .intercept({ method: 'GET', path: '/api/users/@me' }) .reply(200, JSON.stringify(newUser)) const discordMock = fetchMock.get('https://discordapp.com') discordMock .intercept({ method: 'POST', path: '/api/oauth2/token' }) .reply(200, JSON.stringify({ access_token: '1234' })) const providerId = '123456' const res = await request('/v1/auth/discord/callback', { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json' } }) const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>() expect(res.status).toBe(httpStatus.OK) expect(body.user).not.toHaveProperty('password') expect(body.user).toEqual({ id: userOne.id, name: userOne.name, email: userOne.email, role: userOne.role, is_email_verified: 0 }) expect(body.tokens).toEqual({ access: { token: expect.anything(), expires: expect.anything() }, refresh: { token: expect.anything(), expires: expect.anything() } }) }) test('should return 403 if user exists but has not linked their discord', async () => { await insertUsers([userOne], config.database) newUser.email = userOne.email const discordApiMock = fetchMock.get('https://discord.com') discordApiMock .intercept({ method: 'GET', path: '/api/users/@me' }) .reply(200, JSON.stringify(newUser)) const discordMock = fetchMock.get('https://discordapp.com') discordMock .intercept({ method: 'POST', path: '/api/oauth2/token' }) .reply(200, JSON.stringify({ access_token: '1234' })) const providerId = '123456' const res = await request('/v1/auth/discord/callback', { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json' } }) const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>() expect(res.status).toBe(httpStatus.FORBIDDEN) expect(body).toEqual({ code: httpStatus.FORBIDDEN, message: 'Cannot signup with discord, user already exists with that email' }) }) test('should return 401 if code is invalid', async () => { const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const discordMock = fetchMock.get('https://discordapp.com') discordMock .intercept({ method: 'POST', path: '/api/oauth2/token' }) .reply(httpStatus.UNAUTHORIZED, JSON.stringify({ error: 'error' })) const providerId = '123456' const res = await request('/v1/auth/discord/callback', { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) }) test('should return 400 if no code provided', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const res = await request('/v1/auth/discord/callback', { method: 'POST', body: JSON.stringify({ platform: 'web' }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 error if platform is not provided', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const providerId = '123456' const res = await request('/v1/auth/discord/callback', { method: 'POST', body: JSON.stringify({ code: providerId }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 error if platform is invalid', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const providerId = '123456' const res = await request('/v1/auth/discord/callback', { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'wb' }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) }) describe('POST /v1/auth/discord/:userId', () => { let newUser: DiscordUserType beforeAll(async () => { newUser = { id: faker.number.int().toString(), username: faker.person.fullName(), email: faker.internet.email() } fetchMock.activate() }) afterEach(() => fetchMock.assertNoPendingInterceptors()) test('should return 200 and successfully link discord account', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const discordApiMock = fetchMock.get('https://discord.com') discordApiMock .intercept({ method: 'GET', path: '/api/users/@me' }) .reply(200, JSON.stringify(newUser)) const discordMock = fetchMock.get('https://discordapp.com') discordMock .intercept({ method: 'POST', path: '/api/oauth2/token' }) .reply(200, JSON.stringify({ access_token: '1234' })) const providerId = '123456' const res = await request(`/v1/auth/discord/${userOne.id}`, { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.NO_CONTENT) const dbUser = await client .selectFrom('user') .selectAll() .where('user.id', '=', userOne.id) .executeTakeFirst() expect(dbUser).toBeDefined() if (!dbUser) return expect(dbUser.password).toBeDefined() expect(dbUser).toMatchObject({ name: userOne.name, password: expect.anything(), email: userOne.email, role: userOne.role, is_email_verified: 0 }) const oauthUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.DISCORD) .where('authorisations.user_id', '=', userOne.id) .where('authorisations.provider_user_id', '=', newUser.id) .executeTakeFirst() expect(oauthUser).toBeDefined() if (!oauthUser) return }) test('should return 401 if user does not exist when linking', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) await client.deleteFrom('user').where('user.id', '=', userOne.id).execute() const discordApiMock = fetchMock.get('https://discord.com') discordApiMock .intercept({ method: 'GET', path: '/api/users/@me' }) .reply(200, JSON.stringify(newUser)) const discordMock = fetchMock.get('https://discordapp.com') discordMock .intercept({ method: 'POST', path: '/api/oauth2/token' }) .reply(200, JSON.stringify({ access_token: '1234' })) const providerId = '123456' const res = await request(`/v1/auth/discord/${userOne.id}`, { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) const oauthUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.DISCORD) .where('authorisations.user_id', '=', userOne.id) .where('authorisations.provider_user_id', '=', newUser.id) .executeTakeFirst() expect(oauthUser).toBeUndefined() }) test('should return 401 if code is invalid', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const discordMock = fetchMock.get('https://discordapp.com') discordMock .intercept({ method: 'POST', path: '/api/oauth2/token' }) .reply(httpStatus.UNAUTHORIZED, JSON.stringify({ error: 'error' })) const providerId = '123456' const res = await request(`/v1/auth/discord/${userOne.id}`, { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) }) test('should return 403 if linking different user', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const providerId = '123456' const res = await request('/v1/auth/discord/5298', { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.FORBIDDEN) }) test('should return 400 if no code provided', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const res = await request(`/v1/auth/discord/${userOne.id}`, { method: 'POST', body: JSON.stringify({ platform: 'web' }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 401 error if access token is missing', async () => { const res = await request('/v1/auth/discord/1234', { method: 'POST', body: JSON.stringify({}), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) }) test('should return 403 if user has not verified their email', async () => { await insertUsers([userTwo], config.database) const accessToken = await getAccessToken( userTwo.id, userTwo.role, config.jwt, tokenTypes.ACCESS, userTwo.is_email_verified ) const res = await request('/v1/auth/discord/5298', { method: 'POST', body: JSON.stringify({}), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` } }) expect(res.status).toBe(httpStatus.FORBIDDEN) }) test('should return 400 error if platform is not provided', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const providerId = '123456' const res = await request(`/v1/auth/discord/${userOne.id}`, { method: 'POST', body: JSON.stringify({ code: providerId }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 error if platform is invalid', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const providerId = '123456' const res = await request(`/v1/auth/discord/${userOne.id}`, { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'wb' }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) }) describe('DELETE /v1/auth/discord/:userId', () => { test('should return 200 and successfully remove discord account link', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const discordUser = discordAuthorisation(userOne.id) await insertAuthorisations([discordUser], config.database) const res = await request(`/v1/auth/discord/${userOne.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.NO_CONTENT) const oauthUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.DISCORD) .where('authorisations.user_id', '=', userOne.id) .executeTakeFirst() expect(oauthUser).toBeUndefined() if (!oauthUser) return }) test('should return 400 if user does not have a local login and only 1 link', async () => { const newUser = { ...userOne, password: null } await insertUsers([newUser], config.database) const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt) const discordUser = discordAuthorisation(newUser.id) await insertAuthorisations([discordUser], config.database) const res = await request(`/v1/auth/discord/${newUser.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) const oauthUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.DISCORD) .where('authorisations.user_id', '=', newUser.id) .executeTakeFirst() expect(oauthUser).toBeDefined() }) test('should return 400 if user does not have discord link', async () => { const newUser = { ...userOne, password: null } await insertUsers([newUser], config.database) const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt) const githubUser = githubAuthorisation(newUser.id) await insertAuthorisations([githubUser], config.database) const facebookUser = facebookAuthorisation(newUser.id) await insertAuthorisations([facebookUser], config.database) const res = await request(`/v1/auth/discord/${newUser.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 if user only has a local login', async () => { const newUser = { ...userOne, password: null } await insertUsers([newUser], config.database) const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt) const res = await request(`/v1/auth/discord/${newUser.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 200 if user does not have a local login and 2 links', async () => { const newUser = { ...userOne, password: null } await insertUsers([newUser], config.database) const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt) const discordUser = discordAuthorisation(newUser.id) const facebookUser = facebookAuthorisation(newUser.id) await insertAuthorisations([discordUser, facebookUser], config.database) const res = await request(`/v1/auth/discord/${newUser.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.NO_CONTENT) const oauthDiscordUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.DISCORD) .where('authorisations.user_id', '=', newUser.id) .executeTakeFirst() expect(oauthDiscordUser).toBeUndefined() const oauthFacebookUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.FACEBOOK) .where('authorisations.user_id', '=', newUser.id) .executeTakeFirst() expect(oauthFacebookUser).toBeDefined() }) test('should return 403 if unlinking different user', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const res = await request('/v1/auth/discord/5298', { method: 'DELETE', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.FORBIDDEN) }) test('should return 401 error if access token is missing', async () => { const res = await request('/v1/auth/discord/1234', { method: 'DELETE', headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) }) test('should return 403 if user has not verified their email', async () => { await insertUsers([userTwo], config.database) const accessToken = await getAccessToken( userTwo.id, userTwo.role, config.jwt, tokenTypes.ACCESS, userTwo.is_email_verified ) const res = await request('/v1/auth/discord/5298', { method: 'DELETE', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` } }) expect(res.status).toBe(httpStatus.FORBIDDEN) }) }) }) ================================================ FILE: tests/integration/auth/oauth/facebook.test.ts ================================================ import { faker } from '@faker-js/faker' import { env, fetchMock } from 'cloudflare:test' import httpStatus from 'http-status' import { describe, expect, test, beforeAll, afterEach } from 'vitest' import { authProviders } from '../../../../src/config/authProviders' import { getConfig } from '../../../../src/config/config' import { getDBClient } from '../../../../src/config/database' import { tokenTypes } from '../../../../src/config/tokens' import { FacebookUserType } from '../../../../src/types/oauth.types' import { facebookAuthorisation, githubAuthorisation, googleAuthorisation, insertAuthorisations } from '../../../fixtures/authorisations.fixture' import { getAccessToken, TokenResponse } from '../../../fixtures/token.fixture' import { userOne, insertUsers, UserResponse, userTwo } from '../../../fixtures/user.fixture' import { clearDBTables } from '../../../utils/clear-db-tables' import { request } from '../../../utils/test-request' const config = getConfig(env) const client = getDBClient(config.database) clearDBTables(['user', 'authorisations'], config.database) describe('Oauth Facebook routes', () => { describe('GET /v1/auth/facebook/redirect', () => { test('should return 302 and successfully redirect to facebook', async () => { const state = btoa(JSON.stringify({ platform: 'web' })) const urlEncodedRedirectUrl = encodeURIComponent(config.oauth.platform.web.redirectUrl) const res = await request(`/v1/auth/facebook/redirect?state=${state}`, { method: 'GET' }) expect(res.status).toBe(httpStatus.FOUND) expect(res.headers.get('location')).toBe( 'https://www.facebook.com/v4.0/dialog/oauth?auth_type=rerequest&' + `client_id=${config.oauth.provider.facebook.clientId}&display=popup&` + `redirect_uri=${urlEncodedRedirectUrl}&response_type=code&scope=email%2C%20user_friends` + `&state=${state}` ) }) test('should return 400 error if state is not provided', async () => { const res = await request('/v1/auth/facebook/redirect', { method: 'GET' }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 error if state platform is not provided', async () => { const state = btoa(JSON.stringify({})) const res = await request(`/v1/auth/facebook/redirect?state=${state}`, { method: 'GET' }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 error if state platform is invalid', async () => { const state = btoa(JSON.stringify({ platform: 'fake' })) const res = await request(`/v1/auth/facebook/redirect?state=${state}`, { method: 'GET' }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) }) describe('POST /v1/auth/facebook/callback', () => { let newUser: FacebookUserType beforeAll(async () => { newUser = { id: faker.number.int().toString(), first_name: faker.person.firstName(), last_name: faker.person.lastName(), email: faker.internet.email() } fetchMock.activate() }) afterEach(() => fetchMock.assertNoPendingInterceptors()) test('should return 200 and successfully register user if request data is ok', async () => { const facebookApiMock = fetchMock.get('https://graph.facebook.com') facebookApiMock .intercept({ method: 'GET', path: '/me?fields=id,email,first_name,last_name&access_token=1234' }) .reply(200, JSON.stringify(newUser)) facebookApiMock .intercept({ method: 'POST', path: '/v4.0/oauth/access_token' }) .reply(200, JSON.stringify({ access_token: '1234' })) const providerId = '123456' const res = await request('/v1/auth/facebook/callback', { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json' } }) const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>() expect(res.status).toBe(httpStatus.OK) expect(body.user).not.toHaveProperty('password') expect(body.user).toEqual({ id: expect.anything(), name: `${newUser.first_name} ${newUser.last_name}`, email: newUser.email, role: 'user', is_email_verified: 1 }) const dbUser = await client .selectFrom('user') .selectAll() .where('user.id', '=', body.user.id) .executeTakeFirst() expect(dbUser).toBeDefined() if (!dbUser) return expect(dbUser.password).toBeNull() expect(dbUser).toMatchObject({ name: `${newUser.first_name} ${newUser.last_name}`, password: null, email: newUser.email, role: 'user', is_email_verified: 1 }) const oauthUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.FACEBOOK) .where('authorisations.user_id', '=', body.user.id) .where('authorisations.provider_user_id', '=', String(newUser.id)) .executeTakeFirst() expect(oauthUser).toBeDefined() if (!oauthUser) return expect(body.tokens).toEqual({ access: { token: expect.anything(), expires: expect.anything() }, refresh: { token: expect.anything(), expires: expect.anything() } }) }) test('should return 200 and successfully login user if already created', async () => { await insertUsers([userOne], config.database) const facebookUser = facebookAuthorisation(userOne.id) await insertAuthorisations([facebookUser], config.database) newUser.id = facebookUser.provider_user_id const facebookApiMock = fetchMock.get('https://graph.facebook.com') facebookApiMock .intercept({ method: 'GET', path: '/me?fields=id,email,first_name,last_name&access_token=1234' }) .reply(200, JSON.stringify(newUser)) facebookApiMock .intercept({ method: 'POST', path: '/v4.0/oauth/access_token' }) .reply(200, JSON.stringify({ access_token: '1234' })) const providerId = '123456' const res = await request('/v1/auth/facebook/callback', { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json' } }) const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>() expect(res.status).toBe(httpStatus.OK) expect(body.user).not.toHaveProperty('password') expect(body.user).toEqual({ id: userOne.id, name: userOne.name, email: userOne.email, role: userOne.role, is_email_verified: 0 }) expect(body.tokens).toEqual({ access: { token: expect.anything(), expires: expect.anything() }, refresh: { token: expect.anything(), expires: expect.anything() } }) }) test('should return 403 if user exists but has not linked their facebook', async () => { await insertUsers([userOne], config.database) newUser.email = userOne.email const facebookApiMock = fetchMock.get('https://graph.facebook.com') facebookApiMock .intercept({ method: 'GET', path: '/me?fields=id,email,first_name,last_name&access_token=1234' }) .reply(200, JSON.stringify(newUser)) facebookApiMock .intercept({ method: 'POST', path: '/v4.0/oauth/access_token' }) .reply(200, JSON.stringify({ access_token: '1234' })) const providerId = '123456' const res = await request('/v1/auth/facebook/callback', { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json' } }) const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>() expect(res.status).toBe(httpStatus.FORBIDDEN) expect(body).toEqual({ code: httpStatus.FORBIDDEN, message: 'Cannot signup with facebook, user already exists with that email' }) }) test('should return 401 if code is invalid', async () => { const facebookApiMock = fetchMock.get('https://graph.facebook.com') facebookApiMock .intercept({ method: 'POST', path: '/v4.0/oauth/access_token' }) .reply(httpStatus.UNAUTHORIZED, JSON.stringify({ error: 'error' })) const providerId = '123456' const res = await request('/v1/auth/facebook/callback', { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) }) test('should return 400 if no code provided', async () => { const res = await request('/v1/auth/facebook/callback', { method: 'POST', body: JSON.stringify({ platform: 'web' }), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 error if platform is not provided', async () => { const providerId = '123456' const res = await request('/v1/auth/facebook/callback', { method: 'POST', body: JSON.stringify({ code: providerId }), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 error if platform is invalid', async () => { const providerId = '123456' const res = await request('/v1/auth/facebook/callback', { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'wb' }), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) }) describe('POST /v1/auth/facebook/:userId', () => { let newUser: FacebookUserType beforeAll(async () => { newUser = { id: faker.number.int().toString(), first_name: faker.person.firstName(), last_name: faker.person.lastName(), email: faker.internet.email() } fetchMock.activate() }) afterEach(() => fetchMock.assertNoPendingInterceptors()) test('should return 200 and successfully link facebook account', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const facebookApiMock = fetchMock.get('https://graph.facebook.com') facebookApiMock .intercept({ method: 'GET', path: '/me?fields=id,email,first_name,last_name&access_token=1234' }) .reply(200, JSON.stringify(newUser)) facebookApiMock .intercept({ method: 'POST', path: '/v4.0/oauth/access_token' }) .reply(200, JSON.stringify({ access_token: '1234' })) const providerId = '123456' const res = await request(`/v1/auth/facebook/${userOne.id}`, { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.NO_CONTENT) const dbUser = await client .selectFrom('user') .selectAll() .where('user.id', '=', userOne.id) .executeTakeFirst() expect(dbUser).toBeDefined() if (!dbUser) return expect(dbUser.password).toBeDefined() expect(dbUser).toMatchObject({ name: userOne.name, password: expect.anything(), email: userOne.email, role: userOne.role, is_email_verified: 0 }) const oauthUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.FACEBOOK) .where('authorisations.user_id', '=', userOne.id) .where('authorisations.provider_user_id', '=', String(newUser.id)) .executeTakeFirst() expect(oauthUser).toBeDefined() if (!oauthUser) return }) test('should return 401 if user does not exist when linking', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) await client.deleteFrom('user').where('user.id', '=', userOne.id).execute() const facebookApiMock = fetchMock.get('https://graph.facebook.com') facebookApiMock .intercept({ method: 'GET', path: '/me?fields=id,email,first_name,last_name&access_token=1234' }) .reply(200, JSON.stringify(newUser)) facebookApiMock .intercept({ method: 'POST', path: '/v4.0/oauth/access_token' }) .reply(200, JSON.stringify({ access_token: '1234' })) const providerId = '123456' const res = await request(`/v1/auth/facebook/${userOne.id}`, { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) const oauthUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.FACEBOOK) .where('authorisations.user_id', '=', userOne.id) .where('authorisations.provider_user_id', '=', String(newUser.id)) .executeTakeFirst() expect(oauthUser).toBeUndefined() }) test('should return 401 if code is invalid', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const facebookApiMock = fetchMock.get('https://graph.facebook.com') facebookApiMock .intercept({ method: 'POST', path: '/v4.0/oauth/access_token' }) .reply(httpStatus.UNAUTHORIZED, JSON.stringify({ error: 'error' })) const providerId = '123456' const res = await request(`/v1/auth/facebook/${userOne.id}`, { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) }) test('should return 403 if linking different user', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const providerId = '123456' const res = await request('/v1/auth/facebook/5298', { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.FORBIDDEN) }) test('should return 400 if no code provided', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const res = await request(`/v1/auth/facebook/${userOne.id}`, { method: 'POST', body: JSON.stringify({ platform: 'web' }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 401 error if access token is missing', async () => { const res = await request('/v1/auth/facebook/1234', { method: 'POST', body: JSON.stringify({}), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) }) test('should return 403 if user has not verified their email', async () => { await insertUsers([userTwo], config.database) const accessToken = await getAccessToken( userTwo.id, userTwo.role, config.jwt, tokenTypes.ACCESS, userTwo.is_email_verified ) const res = await request('/v1/auth/facebook/5298', { method: 'POST', body: JSON.stringify({}), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` } }) expect(res.status).toBe(httpStatus.FORBIDDEN) }) test('should return 400 error if platform is not provided', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const providerId = '123456' const res = await request(`/v1/auth/facebook/${userOne.id}`, { method: 'POST', body: JSON.stringify({ code: providerId }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 error if platform is invalid', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const providerId = '123456' const res = await request(`/v1/auth/facebook/${userOne.id}`, { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'wb' }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) }) describe('DELETE /v1/auth/facebook/:userId', () => { test('should return 200 and successfully remove facebook account link', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const facebookUser = facebookAuthorisation(userOne.id) await insertAuthorisations([facebookUser], config.database) const res = await request(`/v1/auth/facebook/${userOne.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.NO_CONTENT) const oauthUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.FACEBOOK) .where('authorisations.user_id', '=', userOne.id) .executeTakeFirst() expect(oauthUser).toBeUndefined() if (!oauthUser) return }) test('should return 400 if user does not have a local login and only 1 link', async () => { const newUser = { ...userOne, password: null } await insertUsers([newUser], config.database) const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt) const facebookUser = facebookAuthorisation(newUser.id) await insertAuthorisations([facebookUser], config.database) const res = await request(`/v1/auth/facebook/${newUser.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) const oauthUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.FACEBOOK) .where('authorisations.user_id', '=', newUser.id) .executeTakeFirst() expect(oauthUser).toBeDefined() }) test('should return 400 if user does not have facebook link', async () => { const newUser = { ...userOne, password: null } await insertUsers([newUser], config.database) const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt) const githubUser = githubAuthorisation(newUser.id) await insertAuthorisations([githubUser], config.database) const googleUser = googleAuthorisation(newUser.id) await insertAuthorisations([googleUser], config.database) const res = await request(`/v1/auth/facebook/${newUser.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 if user only has a local login', async () => { const newUser = { ...userOne, password: null } await insertUsers([newUser], config.database) const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt) const res = await request(`/v1/auth/facebook/${newUser.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 200 if user does not have a local login and 2 links', async () => { const newUser = { ...userOne, password: null } await insertUsers([newUser], config.database) const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt) const facebookUser = facebookAuthorisation(newUser.id) const githubUser = githubAuthorisation(newUser.id) await insertAuthorisations([facebookUser, githubUser], config.database) const res = await request(`/v1/auth/facebook/${newUser.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.NO_CONTENT) const oauthFacebookUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.FACEBOOK) .where('authorisations.user_id', '=', newUser.id) .executeTakeFirst() expect(oauthFacebookUser).toBeUndefined() const oauthGithubUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.GITHUB) .where('authorisations.user_id', '=', newUser.id) .executeTakeFirst() expect(oauthGithubUser).toBeDefined() }) test('should return 403 if unlinking different user', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const res = await request('/v1/auth/facebook/5298', { method: 'DELETE', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.FORBIDDEN) }) test('should return 401 error if access token is missing', async () => { const res = await request('/v1/auth/facebook/1234', { method: 'DELETE', headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) }) test('should return 403 if user has not verified their email', async () => { await insertUsers([userTwo], config.database) const accessToken = await getAccessToken( userTwo.id, userTwo.role, config.jwt, tokenTypes.ACCESS, userTwo.is_email_verified ) const res = await request('/v1/auth/facebook/5298', { method: 'DELETE', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` } }) expect(res.status).toBe(httpStatus.FORBIDDEN) }) }) }) ================================================ FILE: tests/integration/auth/oauth/github.test.ts ================================================ import { faker } from '@faker-js/faker' import { env, fetchMock } from 'cloudflare:test' import httpStatus from 'http-status' import { describe, expect, test, beforeAll, afterEach } from 'vitest' import { authProviders } from '../../../../src/config/authProviders' import { getConfig } from '../../../../src/config/config' import { getDBClient } from '../../../../src/config/database' import { tokenTypes } from '../../../../src/config/tokens' import { GithubUserType } from '../../../../src/types/oauth.types' import { appleAuthorisation, githubAuthorisation, googleAuthorisation, insertAuthorisations } from '../../../fixtures/authorisations.fixture' import { getAccessToken, TokenResponse } from '../../../fixtures/token.fixture' import { userOne, insertUsers, UserResponse, userTwo } from '../../../fixtures/user.fixture' import { clearDBTables } from '../../../utils/clear-db-tables' import { request } from '../../../utils/test-request' const config = getConfig(env) const client = getDBClient(config.database) clearDBTables(['user', 'authorisations'], config.database) describe('Oauth routes', () => { describe('GET /v1/auth/github/redirect', () => { test('should return 302 and successfully redirect to github', async () => { const state = btoa(JSON.stringify({ platform: 'web' })) const urlEncodedRedirectUrl = encodeURIComponent(config.oauth.platform.web.redirectUrl) const res = await request(`/v1/auth/github/redirect?state=${state}`, { method: 'GET' }) expect(res.status).toBe(httpStatus.FOUND) expect(res.headers.get('location')).toBe( 'https://github.com/login/oauth/authorize?allow_signup=true&' + `client_id=${config.oauth.provider.github.clientId}&` + `redirect_uri=${urlEncodedRedirectUrl}&scope=read%3Auser%20user%3Aemail&state=${state}` ) }) test('should return 400 error if state is not provided', async () => { const res = await request('/v1/auth/github/redirect', { method: 'GET' }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 error if state platform is not provided', async () => { const state = btoa(JSON.stringify({})) const res = await request(`/v1/auth/github/redirect?state=${state}`, { method: 'GET' }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 error if state platform is invalid', async () => { const state = btoa(JSON.stringify({ platform: 'fake' })) const res = await request(`/v1/auth/github/redirect?state=${state}`, { method: 'GET' }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) }) describe('POST /v1/auth/github/:userId', () => { let newUser: GithubUserType beforeAll(async () => { newUser = { id: faker.number.int(), name: faker.person.fullName(), email: faker.internet.email() } fetchMock.activate() }) afterEach(() => fetchMock.assertNoPendingInterceptors()) test('should return 200 and successfully link github account', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const githubApiMock = fetchMock.get('https://api.github.com') githubApiMock.intercept({ method: 'GET', path: '/user' }).reply(200, JSON.stringify(newUser)) const githubMock = fetchMock.get('https://github.com') githubMock .intercept({ method: 'POST', path: '/login/oauth/access_token' }) .reply(200, JSON.stringify({ access_token: '1234' })) const providerId = '123456' const res = await request(`/v1/auth/github/${userOne.id}`, { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.NO_CONTENT) const dbUser = await client .selectFrom('user') .selectAll() .where('user.id', '=', userOne.id) .executeTakeFirst() expect(dbUser).toBeDefined() if (!dbUser) return expect(dbUser.password).toBeDefined() expect(dbUser).toMatchObject({ name: userOne.name, password: expect.anything(), email: userOne.email, role: userOne.role, is_email_verified: 0 }) const oauthUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.GITHUB) .where('authorisations.user_id', '=', userOne.id) .where('authorisations.provider_user_id', '=', String(newUser.id)) .executeTakeFirst() expect(oauthUser).toBeDefined() if (!oauthUser) return }) test('should return 401 if user does not exist when linking', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) await client.deleteFrom('user').where('user.id', '=', userOne.id).execute() const githubApiMock = fetchMock.get('https://api.github.com') githubApiMock.intercept({ method: 'GET', path: '/user' }).reply(200, JSON.stringify(newUser)) const githubMock = fetchMock.get('https://github.com') githubMock .intercept({ method: 'POST', path: '/login/oauth/access_token' }) .reply(200, JSON.stringify({ access_token: '1234' })) const providerId = '123456' const res = await request(`/v1/auth/github/${userOne.id}`, { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) const oauthUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.GITHUB) .where('authorisations.user_id', '=', userOne.id) .where('authorisations.provider_user_id', '=', String(newUser.id)) .executeTakeFirst() expect(oauthUser).toBeUndefined() }) test('should return 401 if code is invalid', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const githubMock = fetchMock.get('https://github.com') githubMock .intercept({ method: 'POST', path: '/login/oauth/access_token' }) .reply(httpStatus.UNAUTHORIZED, JSON.stringify({ error: 'error' })) const providerId = '123456' const res = await request(`/v1/auth/github/${userOne.id}`, { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) }) test('should return 403 if linking different user', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const providerId = '123456' const res = await request('/v1/auth/github/5298', { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.FORBIDDEN) }) test('should return 400 if no code provided', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const res = await request(`/v1/auth/github/${userOne.id}`, { method: 'POST', body: JSON.stringify({ platform: 'web' }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 401 error if access token is missing', async () => { const res = await request('/v1/auth/github/1234', { method: 'POST', body: JSON.stringify({}), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) }) test('should return 403 if user has not verified their email', async () => { await insertUsers([userTwo], config.database) const accessToken = await getAccessToken( userTwo.id, userTwo.role, config.jwt, tokenTypes.ACCESS, userTwo.is_email_verified ) const res = await request('/v1/auth/github/5298', { method: 'POST', body: JSON.stringify({}), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` } }) expect(res.status).toBe(httpStatus.FORBIDDEN) }) test('should return 400 error if platform is not provided', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const providerId = '123456' const res = await request(`/v1/auth/github/${userOne.id}`, { method: 'POST', body: JSON.stringify({ code: providerId }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 error if platform is invalid', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const providerId = '123456' const res = await request(`/v1/auth/github/${userOne.id}`, { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'wb' }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) }) describe('DELETE /v1/auth/github/:userId', () => { test('should return 200 and successfully remove github account link', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const githubUser = githubAuthorisation(userOne.id) await insertAuthorisations([githubUser], config.database) const res = await request(`/v1/auth/github/${userOne.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.NO_CONTENT) const oauthUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.GITHUB) .where('authorisations.user_id', '=', userOne.id) .executeTakeFirst() expect(oauthUser).toBeUndefined() if (!oauthUser) return }) test('should return 400 if user does not have a local login and only 1 link', async () => { const newUser = { ...userOne, password: null } await insertUsers([newUser], config.database) const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt) const githubUser = githubAuthorisation(newUser.id) await insertAuthorisations([githubUser], config.database) const res = await request(`/v1/auth/github/${newUser.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) const oauthUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.GITHUB) .where('authorisations.user_id', '=', newUser.id) .executeTakeFirst() expect(oauthUser).toBeDefined() }) test('should return 400 if user does not have github link', async () => { const newUser = { ...userOne, password: null } await insertUsers([newUser], config.database) const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt) const googleUser = googleAuthorisation(newUser.id) await insertAuthorisations([googleUser], config.database) const appleUser = appleAuthorisation(newUser.id) await insertAuthorisations([appleUser], config.database) const res = await request(`/v1/auth/github/${newUser.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 if user only has a local login', async () => { const newUser = { ...userOne, password: null } await insertUsers([newUser], config.database) const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt) const res = await request(`/v1/auth/github/${newUser.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 200 if user does not have a local login and 2 links', async () => { const newUser = { ...userOne, password: null } await insertUsers([newUser], config.database) const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt) const githubUser = githubAuthorisation(newUser.id) const appleUser = appleAuthorisation(newUser.id) await insertAuthorisations([githubUser, appleUser], config.database) const res = await request(`/v1/auth/github/${newUser.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.NO_CONTENT) const oauthGithubUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.GITHUB) .where('authorisations.user_id', '=', newUser.id) .executeTakeFirst() expect(oauthGithubUser).toBeUndefined() const oauthFacebookUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.APPLE) .where('authorisations.user_id', '=', newUser.id) .executeTakeFirst() expect(oauthFacebookUser).toBeDefined() }) test('should return 403 if unlinking different user', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const res = await request('/v1/auth/github/5298', { method: 'DELETE', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.FORBIDDEN) }) test('should return 401 error if access token is missing', async () => { const res = await request('/v1/auth/github/1234', { method: 'DELETE', headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) }) test('should return 403 if user has not verified their email', async () => { await insertUsers([userTwo], config.database) const accessToken = await getAccessToken( userTwo.id, userTwo.role, config.jwt, tokenTypes.ACCESS, userTwo.is_email_verified ) const res = await request('/v1/auth/github/5298', { method: 'DELETE', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` } }) expect(res.status).toBe(httpStatus.FORBIDDEN) }) }) describe('POST /v1/auth/github/callback', () => { let newUser: GithubUserType beforeAll(async () => { newUser = { id: faker.number.int(), name: faker.person.fullName(), email: faker.internet.email() } fetchMock.activate() }) afterEach(async () => fetchMock.assertNoPendingInterceptors()) test('should return 200 and successfully register user if request data is ok', async () => { const githubApiMock = fetchMock.get('https://api.github.com') githubApiMock.intercept({ method: 'GET', path: '/user' }).reply(200, JSON.stringify(newUser)) const githubMock = fetchMock.get('https://github.com') githubMock .intercept({ method: 'POST', path: '/login/oauth/access_token' }) .reply(200, JSON.stringify({ access_token: '1234' })) const providerId = '123456' const res = await request('/v1/auth/github/callback', { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json' } }) const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>() expect(res.status).toBe(httpStatus.OK) expect(body.user).not.toHaveProperty('password') expect(body.user).toEqual({ id: expect.anything(), name: newUser.name, email: newUser.email, role: 'user', is_email_verified: 1 }) const dbUser = await client .selectFrom('user') .selectAll() .where('user.id', '=', body.user.id) .executeTakeFirst() expect(dbUser).toBeDefined() if (!dbUser) return expect(dbUser.password).toBeNull() expect(dbUser).toMatchObject({ name: newUser.name, password: null, email: newUser.email, role: 'user', is_email_verified: 1 }) const oauthUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.GITHUB) .where('authorisations.user_id', '=', body.user.id) .where('authorisations.provider_user_id', '=', String(newUser.id)) .executeTakeFirst() expect(oauthUser).toBeDefined() if (!oauthUser) return expect(body.tokens).toEqual({ access: { token: expect.anything(), expires: expect.anything() }, refresh: { token: expect.anything(), expires: expect.anything() } }) }) test('should return 200 and successfully login user if already created', async () => { await insertUsers([userOne], config.database) const githubUser = githubAuthorisation(userOne.id) await insertAuthorisations([githubUser], config.database) newUser.id = parseInt(githubUser.provider_user_id) const githubApiMock = fetchMock.get('https://api.github.com') githubApiMock.intercept({ method: 'GET', path: '/user' }).reply(200, JSON.stringify(newUser)) const githubMock = fetchMock.get('https://github.com') githubMock .intercept({ method: 'POST', path: '/login/oauth/access_token' }) .reply(200, JSON.stringify({ access_token: '1234' })) const providerId = '123456' const res = await request('/v1/auth/github/callback', { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json' } }) const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>() expect(res.status).toBe(httpStatus.OK) expect(body.user).not.toHaveProperty('password') expect(body.user).toEqual({ id: userOne.id, name: userOne.name, email: userOne.email, role: userOne.role, is_email_verified: 0 }) expect(body.tokens).toEqual({ access: { token: expect.anything(), expires: expect.anything() }, refresh: { token: expect.anything(), expires: expect.anything() } }) }) test('should return 403 if user exists but has not linked their github', async () => { await insertUsers([userOne], config.database) newUser.email = userOne.email const githubApiMock = fetchMock.get('https://api.github.com') githubApiMock.intercept({ method: 'GET', path: '/user' }).reply(200, JSON.stringify(newUser)) const githubMock = fetchMock.get('https://github.com') githubMock .intercept({ method: 'POST', path: '/login/oauth/access_token' }) .reply(200, JSON.stringify({ access_token: '1234' })) const providerId = '123456' const res = await request('/v1/auth/github/callback', { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json' } }) const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>() expect(res.status).toBe(httpStatus.FORBIDDEN) expect(body).toEqual({ code: httpStatus.FORBIDDEN, message: 'Cannot signup with github, user already exists with that email' }) }) test('should return 401 if code is invalid', async () => { const providerId = '123456' const res = await request('/v1/auth/github/callback', { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) }) test('should return 400 if no code provided', async () => { const res = await request('/v1/auth/github/callback', { method: 'POST', body: JSON.stringify({}), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 error if platform is not provided', async () => { const providerId = '123456' const res = await request('/v1/auth/github/callback', { method: 'POST', body: JSON.stringify({ code: providerId }), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 error if platform is invalid', async () => { const providerId = '123456' const res = await request('/v1/auth/github/callback', { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'wb' }), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) }) }) ================================================ FILE: tests/integration/auth/oauth/google.test.ts ================================================ import { faker } from '@faker-js/faker' import { env, fetchMock } from 'cloudflare:test' import httpStatus from 'http-status' import { describe, expect, test, beforeAll, afterEach } from 'vitest' import { authProviders } from '../../../../src/config/authProviders' import { getConfig } from '../../../../src/config/config' import { getDBClient } from '../../../../src/config/database' import { tokenTypes } from '../../../../src/config/tokens' import { GoogleUserType } from '../../../../src/types/oauth.types' import { appleAuthorisation, githubAuthorisation, googleAuthorisation, insertAuthorisations } from '../../../fixtures/authorisations.fixture' import { getAccessToken, TokenResponse } from '../../../fixtures/token.fixture' import { userOne, insertUsers, UserResponse, userTwo } from '../../../fixtures/user.fixture' import { clearDBTables } from '../../../utils/clear-db-tables' import { request } from '../../../utils/test-request' const config = getConfig(env) const client = getDBClient(config.database) clearDBTables(['user', 'authorisations'], config.database) describe('Oauth Google routes', () => { describe('GET /v1/auth/google/redirect', () => { test('should return 302 and successfully redirect to google', async () => { const state = btoa(JSON.stringify({ platform: 'web' })) const urlEncodedRedirectUrl = encodeURIComponent(config.oauth.platform.web.redirectUrl) const res = await request(`/v1/auth/google/redirect?state=${state}`, { method: 'GET' }) expect(res.status).toBe(httpStatus.FOUND) expect(res.headers.get('location')).toBe( 'https://accounts.google.com/o/oauth2/v2/auth?client_id=' + `${config.oauth.provider.google.clientId}&` + `include_granted_scopes=true&redirect_uri=${urlEncodedRedirectUrl}&` + `response_type=code&scope=openid%20email%20profile&state=${state}` ) }) test('should return 400 error if state is not provided', async () => { const res = await request('/v1/auth/google/redirect', { method: 'GET' }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 error if state platform is not provided', async () => { const state = btoa(JSON.stringify({})) const res = await request(`/v1/auth/google/redirect?state=${state}`, { method: 'GET' }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 error if state platform is invalid', async () => { const state = btoa(JSON.stringify({ platform: 'fake' })) const res = await request(`/v1/auth/google/redirect?state=${state}`, { method: 'GET' }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) }) describe('POST /v1/auth/google/callback', () => { let newUser: GoogleUserType beforeAll(async () => { newUser = { id: faker.number.int().toString(), name: faker.person.fullName(), email: faker.internet.email() } fetchMock.activate() }) afterEach(async () => fetchMock.assertNoPendingInterceptors()) test('should return 200 and successfully register user if request data is ok', async () => { const googleApiMock = fetchMock.get('https://www.googleapis.com') googleApiMock .intercept({ method: 'GET', path: '/oauth2/v2/userinfo' }) .reply(200, JSON.stringify(newUser)) const googleMock = fetchMock.get('https://oauth2.googleapis.com') googleMock .intercept({ method: 'POST', path: '/token' }) .reply(200, JSON.stringify({ access_token: '1234' })) const providerId = '123456' const res = await request('/v1/auth/google/callback', { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json' } }) const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>() expect(res.status).toBe(httpStatus.OK) expect(body.user).not.toHaveProperty('password') expect(body.user).toEqual({ id: expect.anything(), name: newUser.name, email: newUser.email, role: 'user', is_email_verified: 1 }) const dbUser = await client .selectFrom('user') .selectAll() .where('user.id', '=', body.user.id) .executeTakeFirst() expect(dbUser).toBeDefined() if (!dbUser) return expect(dbUser.password).toBeNull() expect(dbUser).toMatchObject({ name: newUser.name, password: null, email: newUser.email, role: 'user', is_email_verified: 1 }) const oauthUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.GOOGLE) .where('authorisations.user_id', '=', body.user.id) .where('authorisations.provider_user_id', '=', newUser.id) .executeTakeFirst() expect(oauthUser).toBeDefined() if (!oauthUser) return expect(body.tokens).toEqual({ access: { token: expect.anything(), expires: expect.anything() }, refresh: { token: expect.anything(), expires: expect.anything() } }) }) test('should return 200 and successfully login user if already created', async () => { await insertUsers([userOne], config.database) const googleUser = googleAuthorisation(userOne.id) await insertAuthorisations([googleUser], config.database) newUser.id = googleUser.provider_user_id const googleApiMock = fetchMock.get('https://www.googleapis.com') googleApiMock .intercept({ method: 'GET', path: '/oauth2/v2/userinfo' }) .reply(200, JSON.stringify(newUser)) const googleMock = fetchMock.get('https://oauth2.googleapis.com') googleMock .intercept({ method: 'POST', path: '/token' }) .reply(200, JSON.stringify({ access_token: '1234' })) const providerId = '123456' const res = await request('/v1/auth/google/callback', { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json' } }) const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>() expect(res.status).toBe(httpStatus.OK) expect(body.user).not.toHaveProperty('password') expect(body.user).toEqual({ id: userOne.id, name: userOne.name, email: userOne.email, role: userOne.role, is_email_verified: 0 }) expect(body.tokens).toEqual({ access: { token: expect.anything(), expires: expect.anything() }, refresh: { token: expect.anything(), expires: expect.anything() } }) }) test('should return 403 if user exists but has not linked their google', async () => { await insertUsers([userOne], config.database) newUser.email = userOne.email const googleApiMock = fetchMock.get('https://www.googleapis.com') googleApiMock .intercept({ method: 'GET', path: '/oauth2/v2/userinfo' }) .reply(200, JSON.stringify(newUser)) const googleMock = fetchMock.get('https://oauth2.googleapis.com') googleMock .intercept({ method: 'POST', path: '/token' }) .reply(200, JSON.stringify({ access_token: '1234' })) const providerId = '123456' const res = await request('/v1/auth/google/callback', { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json' } }) const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>() expect(res.status).toBe(httpStatus.FORBIDDEN) expect(body).toEqual({ code: httpStatus.FORBIDDEN, message: 'Cannot signup with google, user already exists with that email' }) }) test('should return 401 if code is invalid', async () => { const googleMock = fetchMock.get('https://oauth2.googleapis.com') googleMock .intercept({ method: 'POST', path: '/token' }) .reply(httpStatus.UNAUTHORIZED, JSON.stringify({ error: 'error' })) const providerId = '123456' const res = await request('/v1/auth/google/callback', { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) }) test('should return 400 if no code provided', async () => { const res = await request('/v1/auth/google/callback', { method: 'POST', body: JSON.stringify({ platform: 'web' }), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 error if platform is not provided', async () => { const providerId = '123456' const res = await request('/v1/auth/google/callback', { method: 'POST', body: JSON.stringify({ code: providerId }), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 error if platform is invalid', async () => { const providerId = '123456' const res = await request('/v1/auth/google/callback', { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'wb' }), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) }) describe('POST /v1/auth/google/:userId', () => { let newUser: GoogleUserType beforeAll(async () => { newUser = { id: faker.number.int().toString(), name: faker.person.fullName(), email: faker.internet.email() } fetchMock.activate() }) afterEach(async () => fetchMock.assertNoPendingInterceptors()) test('should return 200 and successfully link google account', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const googleApiMock = fetchMock.get('https://www.googleapis.com') googleApiMock .intercept({ method: 'GET', path: '/oauth2/v2/userinfo' }) .reply(200, JSON.stringify(newUser)) const googleMock = fetchMock.get('https://oauth2.googleapis.com') googleMock .intercept({ method: 'POST', path: '/token' }) .reply(200, JSON.stringify({ access_token: '1234' })) const providerId = '123456' const res = await request(`/v1/auth/google/${userOne.id}`, { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.NO_CONTENT) const dbUser = await client .selectFrom('user') .selectAll() .where('user.id', '=', userOne.id) .executeTakeFirst() expect(dbUser).toBeDefined() if (!dbUser) return expect(dbUser.password).toBeDefined() expect(dbUser).toMatchObject({ name: userOne.name, password: expect.anything(), email: userOne.email, role: userOne.role, is_email_verified: 0 }) const oauthUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.GOOGLE) .where('authorisations.user_id', '=', userOne.id) .where('authorisations.provider_user_id', '=', newUser.id) .execute() expect(oauthUser).toBeDefined() if (!oauthUser) return }) test('should return 401 if user does not exist when linking', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) await client.deleteFrom('user').where('user.id', '=', userOne.id).execute() const googleApiMock = fetchMock.get('https://www.googleapis.com') googleApiMock .intercept({ method: 'GET', path: '/oauth2/v2/userinfo' }) .reply(200, JSON.stringify(newUser)) const googleMock = fetchMock.get('https://oauth2.googleapis.com') googleMock .intercept({ method: 'POST', path: '/token' }) .reply(200, JSON.stringify({ access_token: '1234' })) const providerId = '123456' const res = await request(`/v1/auth/google/${userOne.id}`, { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) const oauthUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.GOOGLE) .where('authorisations.user_id', '=', userOne.id) .where('authorisations.provider_user_id', '=', newUser.id) .executeTakeFirst() expect(oauthUser).toBeUndefined() }) test('should return 401 if code is invalid', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const googleMock = fetchMock.get('https://oauth2.googleapis.com') googleMock .intercept({ method: 'POST', path: '/token' }) .reply(httpStatus.UNAUTHORIZED, JSON.stringify({ error: 'error' })) const providerId = '123456' const res = await request(`/v1/auth/google/${userOne.id}`, { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) }) test('should return 403 if linking different user', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const providerId = '123456' const res = await request('/v1/auth/google/5298', { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.FORBIDDEN) }) test('should return 400 if no code provided', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const res = await request(`/v1/auth/google/${userOne.id}`, { method: 'POST', body: JSON.stringify({ platform: 'web' }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 401 error if access token is missing', async () => { const res = await request('/v1/auth/google/1234', { method: 'POST', body: JSON.stringify({}), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) }) test('should return 403 if user has not verified their email', async () => { await insertUsers([userTwo], config.database) const accessToken = await getAccessToken( userTwo.id, userTwo.role, config.jwt, tokenTypes.ACCESS, userTwo.is_email_verified ) const res = await request('/v1/auth/google/5298', { method: 'POST', body: JSON.stringify({}), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` } }) expect(res.status).toBe(httpStatus.FORBIDDEN) }) test('should return 400 error if platform is not provided', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const providerId = '123456' const res = await request(`/v1/auth/google/${userOne.id}`, { method: 'POST', body: JSON.stringify({ code: providerId }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 error if platform is invalid', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const providerId = '123456' const res = await request(`/v1/auth/google/${userOne.id}`, { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'wb' }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) }) describe('DELETE /v1/auth/google/:userId', () => { test('should return 200 and successfully remove google account link', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const googleUser = googleAuthorisation(userOne.id) await insertAuthorisations([googleUser], config.database) const res = await request(`/v1/auth/google/${userOne.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.NO_CONTENT) const oauthUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.GOOGLE) .where('authorisations.user_id', '=', userOne.id) .executeTakeFirst() expect(oauthUser).toBeUndefined() if (!oauthUser) return }) test('should return 400 if user does not have a local login and only 1 link', async () => { const newUser = { ...userOne, password: null } await insertUsers([newUser], config.database) const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt) const googleUser = googleAuthorisation(newUser.id) await insertAuthorisations([googleUser], config.database) const res = await request(`/v1/auth/google/${newUser.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) const oauthUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.GOOGLE) .where('authorisations.user_id', '=', newUser.id) .executeTakeFirst() expect(oauthUser).toBeDefined() }) test('should return 400 if user does not have google link', async () => { const newUser = { ...userOne, password: null } await insertUsers([newUser], config.database) const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt) const githubUser = githubAuthorisation(newUser.id) await insertAuthorisations([githubUser], config.database) const appleUser = appleAuthorisation(newUser.id) await insertAuthorisations([appleUser], config.database) const res = await request(`/v1/auth/google/${newUser.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 if user only has a local login', async () => { const newUser = { ...userOne, password: null } await insertUsers([newUser], config.database) const userId = newUser.id const userOneAccessToken = await getAccessToken(userId, newUser.role, config.jwt) const res = await request(`/v1/auth/google/${userId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 200 if user does not have a local login and 2 links', async () => { const newUser = { ...userOne, password: null } await insertUsers([newUser], config.database) const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt) const googleUser = googleAuthorisation(newUser.id) const appleUser = appleAuthorisation(newUser.id) await insertAuthorisations([googleUser, appleUser], config.database) const res = await request(`/v1/auth/google/${newUser.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.NO_CONTENT) const oauthGoogleUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.GOOGLE) .where('authorisations.user_id', '=', newUser.id) .executeTakeFirst() expect(oauthGoogleUser).toBeUndefined() const oauthFacebookUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.APPLE) .where('authorisations.user_id', '=', newUser.id) .executeTakeFirst() expect(oauthFacebookUser).toBeDefined() }) test('should return 403 if unlinking different user', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const res = await request('/v1/auth/google/5298', { method: 'DELETE', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.FORBIDDEN) }) test('should return 401 error if access token is missing', async () => { const res = await request('/v1/auth/google/1234', { method: 'DELETE', headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) }) test('should return 403 if user has not verified their email', async () => { await insertUsers([userTwo], config.database) const accessToken = await getAccessToken( userTwo.id, userTwo.role, config.jwt, tokenTypes.ACCESS, userTwo.is_email_verified ) const res = await request('/v1/auth/google/5298', { method: 'DELETE', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` } }) expect(res.status).toBe(httpStatus.FORBIDDEN) }) }) }) ================================================ FILE: tests/integration/auth/oauth/spotify.test.ts ================================================ import { faker } from '@faker-js/faker' import { env, fetchMock } from 'cloudflare:test' import httpStatus from 'http-status' import { describe, expect, test, beforeAll, afterEach } from 'vitest' import { authProviders } from '../../../../src/config/authProviders' import { getConfig } from '../../../../src/config/config' import { getDBClient } from '../../../../src/config/database' import { tokenTypes } from '../../../../src/config/tokens' import { SpotifyUserType } from '../../../../src/types/oauth.types' import { spotifyAuthorisation, insertAuthorisations, facebookAuthorisation, githubAuthorisation } from '../../../fixtures/authorisations.fixture' import { getAccessToken, TokenResponse } from '../../../fixtures/token.fixture' import { userOne, insertUsers, UserResponse, userTwo } from '../../../fixtures/user.fixture' import { clearDBTables } from '../../../utils/clear-db-tables' import { request } from '../../../utils/test-request' const config = getConfig(env) const client = getDBClient(config.database) const urlEncodedRedirectUrl = encodeURIComponent(config.oauth.platform.web.redirectUrl) clearDBTables(['user', 'authorisations'], config.database) describe('Oauth Spotify routes', () => { describe('GET /v1/auth/spotify/redirect', () => { test('should return 302 and successfully redirect to spotify', async () => { const state = btoa(JSON.stringify({ platform: 'web' })) const res = await request(`/v1/auth/spotify/redirect?state=${state}`, { method: 'GET' }) expect(res.status).toBe(httpStatus.FOUND) expect(res.headers.get('location')).toBe( 'https://accounts.spotify.com/authorize?client_id=' + `${config.oauth.provider.spotify.clientId}&` + `redirect_uri=${urlEncodedRedirectUrl}&response_type=code&` + `scope=user-read-email&show_dialog=false&state=${state}` ) }) test('should return 400 error if state is not provided', async () => { const res = await request('/v1/auth/spotify/redirect', { method: 'GET' }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 error if state platform is not provided', async () => { const state = btoa(JSON.stringify({})) const res = await request(`/v1/auth/spotify/redirect?state=${state}`, { method: 'GET' }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 error if state platform is invalid', async () => { const state = btoa(JSON.stringify({ platform: 'fake' })) const res = await request(`/v1/auth/spotify/redirect?state=${state}`, { method: 'GET' }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) }) describe('POST /v1/auth/spotify/callback', () => { let newUser: SpotifyUserType beforeAll(async () => { newUser = { id: faker.number.int().toString(), display_name: faker.person.fullName(), email: faker.internet.email() } fetchMock.activate() }) afterEach(() => fetchMock.assertNoPendingInterceptors()) test('should return 200 and successfully register user if request data is ok', async () => { const providerId = '123456' const spotifyApiMock = fetchMock.get('https://api.spotify.com') spotifyApiMock .intercept({ method: 'GET', path: '/v1/me' }) .reply(200, JSON.stringify(newUser)) const spotifyMock = fetchMock.get('https://accounts.spotify.com') spotifyMock .intercept({ method: 'POST', path: `/api/token?code=${providerId}&grant_type=authorization_code&` + `redirect_uri=${urlEncodedRedirectUrl}` }) .reply(200, JSON.stringify({ access_token: '1234' })) const res = await request('/v1/auth/spotify/callback', { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json' } }) const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>() expect(res.status).toBe(httpStatus.OK) expect(body.user).not.toHaveProperty('password') expect(body.user).toEqual({ id: expect.anything(), name: newUser.display_name, email: newUser.email, role: 'user', is_email_verified: 1 }) const dbUser = await client .selectFrom('user') .selectAll() .where('user.id', '=', body.user.id) .executeTakeFirst() expect(dbUser).toBeDefined() if (!dbUser) return expect(dbUser.password).toBeNull() expect(dbUser).toMatchObject({ name: newUser.display_name, password: null, email: newUser.email, role: 'user', is_email_verified: 1 }) const oauthUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.SPOTIFY) .where('authorisations.user_id', '=', body.user.id) .where('authorisations.provider_user_id', '=', String(newUser.id)) .executeTakeFirst() expect(oauthUser).toBeDefined() if (!oauthUser) return expect(body.tokens).toEqual({ access: { token: expect.anything(), expires: expect.anything() }, refresh: { token: expect.anything(), expires: expect.anything() } }) }) test('should return 200 and successfully login user if already created', async () => { await insertUsers([userOne], config.database) const spotifyUser = spotifyAuthorisation(userOne.id) await insertAuthorisations([spotifyUser], config.database) newUser.id = spotifyUser.provider_user_id const providerId = '123456' const spotifyApiMock = fetchMock.get('https://api.spotify.com') spotifyApiMock .intercept({ method: 'GET', path: '/v1/me' }) .reply(200, JSON.stringify(newUser)) const spotifyMock = fetchMock.get('https://accounts.spotify.com') spotifyMock .intercept({ method: 'POST', path: `/api/token?code=${providerId}&grant_type=authorization_code&` + `redirect_uri=${urlEncodedRedirectUrl}` }) .reply(200, JSON.stringify({ access_token: '1234' })) const res = await request('/v1/auth/spotify/callback', { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json' } }) const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>() expect(res.status).toBe(httpStatus.OK) expect(body.user).not.toHaveProperty('password') expect(body.user).toEqual({ id: userOne.id, name: userOne.name, email: userOne.email, role: userOne.role, is_email_verified: 0 }) expect(body.tokens).toEqual({ access: { token: expect.anything(), expires: expect.anything() }, refresh: { token: expect.anything(), expires: expect.anything() } }) }) test('should return 403 if user exists but has not linked their spotify', async () => { await insertUsers([userOne], config.database) newUser.email = userOne.email const providerId = '123456' const spotifyApiMock = fetchMock.get('https://api.spotify.com') spotifyApiMock .intercept({ method: 'GET', path: '/v1/me' }) .reply(200, JSON.stringify(newUser)) const spotifyMock = fetchMock.get('https://accounts.spotify.com') spotifyMock .intercept({ method: 'POST', path: `/api/token?code=${providerId}&grant_type=authorization_code&` + `redirect_uri=${urlEncodedRedirectUrl}` }) .reply(200, JSON.stringify({ access_token: '1234' })) const res = await request('/v1/auth/spotify/callback', { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json' } }) const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>() expect(res.status).toBe(httpStatus.FORBIDDEN) expect(body).toEqual({ code: httpStatus.FORBIDDEN, message: 'Cannot signup with spotify, user already exists with that email' }) }) test('should return 401 if code is invalid', async () => { const providerId = '123456' const spotifyMock = fetchMock.get('https://accounts.spotify.com') spotifyMock .intercept({ method: 'POST', path: `/api/token?code=${providerId}&grant_type=authorization_code&` + `redirect_uri=${urlEncodedRedirectUrl}` }) .reply(httpStatus.UNAUTHORIZED, JSON.stringify({ error: 'error' })) const res = await request('/v1/auth/spotify/callback', { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) }) test('should return 400 if no code provided', async () => { const res = await request('/v1/auth/spotify/callback', { method: 'POST', body: JSON.stringify({ platform: 'web' }), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 error if platform is not provided', async () => { const providerId = '123456' const res = await request('/v1/auth/spotify/callback', { method: 'POST', body: JSON.stringify({ code: providerId }), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 error if platform is invalid', async () => { const providerId = '123456' const res = await request('/v1/auth/spotify/callback', { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'wb' }), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) }) describe('POST /v1/auth/spotify/:userId', () => { let newUser: SpotifyUserType beforeAll(async () => { newUser = { id: faker.number.int().toString(), display_name: faker.person.fullName(), email: faker.internet.email() } fetchMock.activate() }) afterEach(() => fetchMock.assertNoPendingInterceptors()) test('should return 200 and successfully link spotify account', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const providerId = '123456' const spotifyApiMock = fetchMock.get('https://api.spotify.com') spotifyApiMock .intercept({ method: 'GET', path: '/v1/me' }) .reply(200, JSON.stringify(newUser)) const spotifyMock = fetchMock.get('https://accounts.spotify.com') spotifyMock .intercept({ method: 'POST', path: `/api/token?code=${providerId}&grant_type=authorization_code&` + `redirect_uri=${urlEncodedRedirectUrl}` }) .reply(200, JSON.stringify({ access_token: '1234' })) const res = await request(`/v1/auth/spotify/${userOne.id}`, { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.NO_CONTENT) const dbUser = await client .selectFrom('user') .selectAll() .where('user.id', '=', userOne.id) .executeTakeFirst() expect(dbUser).toBeDefined() if (!dbUser) return expect(dbUser.password).toBeDefined() expect(dbUser).toMatchObject({ name: userOne.name, password: expect.anything(), email: userOne.email, role: userOne.role, is_email_verified: 0 }) const oauthUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.SPOTIFY) .where('authorisations.user_id', '=', userOne.id) .where('authorisations.provider_user_id', '=', String(newUser.id)) .executeTakeFirst() expect(oauthUser).toBeDefined() if (!oauthUser) return }) test('should return 401 if user does not exist when linking', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) await client.deleteFrom('user').where('user.id', '=', userOne.id).execute() const providerId = '123456' const spotifyApiMock = fetchMock.get('https://api.spotify.com') spotifyApiMock .intercept({ method: 'GET', path: '/v1/me' }) .reply(200, JSON.stringify(newUser)) const spotifyMock = fetchMock.get('https://accounts.spotify.com') spotifyMock .intercept({ method: 'POST', path: `/api/token?code=${providerId}&grant_type=authorization_code&` + `redirect_uri=${urlEncodedRedirectUrl}` }) .reply(200, JSON.stringify({ access_token: '1234' })) const res = await request(`/v1/auth/spotify/${userOne.id}`, { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) const oauthUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.SPOTIFY) .where('authorisations.user_id', '=', userOne.id) .where('authorisations.provider_user_id', '=', String(newUser.id)) .executeTakeFirst() expect(oauthUser).toBeUndefined() }) test('should return 401 if code is invalid', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const providerId = '123456' const spotifyMock = fetchMock.get('https://accounts.spotify.com') spotifyMock .intercept({ method: 'POST', path: `/api/token?code=${providerId}&grant_type=authorization_code&` + `redirect_uri=${urlEncodedRedirectUrl}` }) .reply(httpStatus.UNAUTHORIZED, JSON.stringify({ error: 'error' })) const res = await request(`/v1/auth/spotify/${userOne.id}`, { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) }) test('should return 403 if linking different user', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const providerId = '123456' const res = await request('/v1/auth/spotify/5298', { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'web' }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.FORBIDDEN) }) test('should return 400 if no code provided', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const res = await request(`/v1/auth/spotify/${userOne.id}`, { method: 'POST', body: JSON.stringify({ platform: 'web' }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 401 error if access token is missing', async () => { const res = await request('/v1/auth/spotify/1234', { method: 'POST', body: JSON.stringify({}), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) }) test('should return 403 if user has not verified their email', async () => { await insertUsers([userTwo], config.database) const accessToken = await getAccessToken( userTwo.id, userTwo.role, config.jwt, tokenTypes.ACCESS, userTwo.is_email_verified ) const res = await request('/v1/auth/spotify/5298', { method: 'POST', body: JSON.stringify({}), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` } }) expect(res.status).toBe(httpStatus.FORBIDDEN) }) test('should return 400 error if platform is not provided', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const providerId = '123456' const res = await request(`/v1/auth/spotify/${userOne.id}`, { method: 'POST', body: JSON.stringify({ code: providerId }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 error if platform is invalid', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const providerId = '123456' const res = await request(`/v1/auth/spotify/${userOne.id}`, { method: 'POST', body: JSON.stringify({ code: providerId, platform: 'wb' }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) }) describe('DELETE /v1/auth/spotify/:userId', () => { test('should return 200 and successfully remove spotify account link', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const spotifyUser = spotifyAuthorisation(userOne.id) await insertAuthorisations([spotifyUser], config.database) const res = await request(`/v1/auth/spotify/${userOne.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.NO_CONTENT) const oauthUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.SPOTIFY) .where('authorisations.user_id', '=', userOne.id) .executeTakeFirst() expect(oauthUser).toBeUndefined() if (!oauthUser) return }) test('should return 400 if user does not have a local login and only 1 link', async () => { const newUser = { ...userOne, password: null } await insertUsers([newUser], config.database) const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt) const spotifyUser = spotifyAuthorisation(newUser.id) await insertAuthorisations([spotifyUser], config.database) const res = await request(`/v1/auth/spotify/${newUser.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) const oauthUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.SPOTIFY) .where('authorisations.user_id', '=', newUser.id) .executeTakeFirst() expect(oauthUser).toBeDefined() }) test('should return 400 if user only has a local login', async () => { const newUser = { ...userOne, password: null } await insertUsers([newUser], config.database) const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt) const res = await request(`/v1/auth/spotify/${newUser.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 if user does not have spotify link', async () => { const newUser = { ...userOne, password: null } await insertUsers([newUser], config.database) const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt) const githubUser = githubAuthorisation(newUser.id) await insertAuthorisations([githubUser], config.database) const facebookUser = facebookAuthorisation(newUser.id) await insertAuthorisations([facebookUser], config.database) const res = await request(`/v1/auth/spotify/${newUser.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 200 if user does not have a local login and 2 links', async () => { const newUser = { ...userOne, password: null } await insertUsers([newUser], config.database) const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt) const spotifyUser = spotifyAuthorisation(newUser.id) const facebookUser = facebookAuthorisation(newUser.id) await insertAuthorisations([spotifyUser, facebookUser], config.database) const res = await request(`/v1/auth/spotify/${newUser.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.NO_CONTENT) const oauthSpotifyUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.SPOTIFY) .where('authorisations.user_id', '=', newUser.id) .executeTakeFirst() expect(oauthSpotifyUser).toBeUndefined() const oauthFacebookUser = await client .selectFrom('authorisations') .selectAll() .where('authorisations.provider_type', '=', authProviders.FACEBOOK) .where('authorisations.user_id', '=', newUser.id) .executeTakeFirst() expect(oauthFacebookUser).toBeDefined() }) test('should return 403 if unlinking different user', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const res = await request('/v1/auth/spotify/5298', { method: 'DELETE', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.FORBIDDEN) }) test('should return 401 error if access token is missing', async () => { const res = await request('/v1/auth/spotify/1234', { method: 'DELETE', headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) }) test('should return 403 if user has not verified their email', async () => { await insertUsers([userTwo], config.database) const accessToken = await getAccessToken( userTwo.id, userTwo.role, config.jwt, tokenTypes.ACCESS, userTwo.is_email_verified ) const res = await request('/v1/auth/spotify/5298', { method: 'DELETE', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` } }) expect(res.status).toBe(httpStatus.FORBIDDEN) }) }) }) ================================================ FILE: tests/integration/index.test.ts ================================================ import httpStatus from 'http-status' import { test, describe, expect } from 'vitest' import { request } from '../utils/test-request' describe('Basic routing', () => { test('should return 404 if route not found', async () => { const res = await request('/idontexist', { method: 'GET' }) expect(res.status).toBe(httpStatus.NOT_FOUND) }) }) ================================================ FILE: tests/integration/rate-limiter.test.ts ================================================ import { env, runInDurableObject, runDurableObjectAlarm } from 'cloudflare:test' import dayjs from 'dayjs' import isSameOrBefore from 'dayjs/plugin/isSameOrBefore' import httpStatus from 'http-status' import MockDate from 'mockdate' import { test, describe, expect, beforeEach } from 'vitest' import { RateLimiter } from '../../src' dayjs.extend(isSameOrBefore) const key = '127.0.0.1' const id = env.RATE_LIMITER.idFromName(key) const fakeDomain = 'http://iamaratelimiter.com/' describe('Durable Object RateLimiter', () => { describe('Fetch /', () => { beforeEach(async () => { const stub = env.RATE_LIMITER.get(id) await runInDurableObject(stub, async (_, state) => { await state.storage.deleteAll() }) MockDate.reset() }) test('should return 200 and not rate limit if limit not hit', async () => { const config = { scope: '/v1/auth/send-verification-email', key, limit: 1, interval: 60 } const rateLimiter = env.RATE_LIMITER.get(id) const res = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => { const res = new Request(fakeDomain, { method: 'POST', body: JSON.stringify(config) }) return await instance.fetch(res) }) const body = await res.json() expect(res.status).toBe(httpStatus.OK) expect(body).toEqual({ blocked: false, remaining: 0, expires: expect.any(String) }) }) test('should return 200 and rate limit if limit hit', async () => { const config = { scope: '/v1/auth/send-verification-email', key, limit: 200, interval: 600 } const currentWindow = Math.floor(dayjs().unix() / config.interval) const storageKey = `${config.scope}|${config.key.toString()}|${config.limit}|` + `${config.interval}|${currentWindow}` const rateLimiter = env.RATE_LIMITER.get(id) await runInDurableObject(rateLimiter, async (_, state) => { await state.storage.put(storageKey, config.limit + 1) }) const start = dayjs() const res = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => { const res = new Request(fakeDomain, { method: 'POST', body: JSON.stringify(config) }) return await instance.fetch(res) }) const body = await res.json() expect(res.status).toBe(httpStatus.OK) expect(body).toEqual({ blocked: true, remaining: 0, expires: expect.any(String) }) const expires = dayjs(res.headers.get('expires')) expect(start.isSameOrBefore(expires)).toBe(true) const cacheControl = res.headers.get('cache-control') expect(cacheControl).toBeDefined() }) test('should return 200 and not rate limit if different endpoint hit', async () => { const config = { scope: '/v1/auth/send-verification-email', key, limit: 200, interval: 600 } const currentWindow = Math.floor(dayjs().unix() / config.interval) const storageKey = `${config.scope}|${config.key.toString()}|${config.limit}|` + `${config.interval}|${currentWindow}` const rateLimiter = env.RATE_LIMITER.get(id) await runInDurableObject(rateLimiter, async (_, state) => { await state.storage.put(storageKey, config.limit + 1) }) const res = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => { const res = new Request(fakeDomain, { method: 'POST', body: JSON.stringify(config) }) return await instance.fetch(res) }) const body = await res.json() expect(res.status).toBe(httpStatus.OK) expect(body).toEqual({ blocked: true, remaining: 0, expires: expect.any(String) }) config.scope = '/v1/different-endpoint' const res2 = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => { const res = new Request(fakeDomain, { method: 'POST', body: JSON.stringify(config) }) return await instance.fetch(res) }) const body2 = await res2.json() expect(res2.status).toBe(httpStatus.OK) expect(body2).toEqual({ blocked: false, remaining: 199, expires: expect.any(String) }) }) test('should return 200 and not rate limit if different key used', async () => { const config = { scope: '/v1/auth/send-verification-email', key, limit: 200, interval: 600 } const currentWindow = Math.floor(dayjs().unix() / config.interval) const storageKey = `${config.scope}|${config.key.toString()}|${config.limit}|` + `${config.interval}|${currentWindow}` const rateLimiter = env.RATE_LIMITER.get(id) await runInDurableObject(rateLimiter, async (_, state) => { await state.storage.put(storageKey, config.limit + 1) }) const res = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => { const res = new Request(fakeDomain, { method: 'POST', body: JSON.stringify(config) }) return await instance.fetch(res) }) const body = await res.json() expect(res.status).toBe(httpStatus.OK) expect(body).toEqual({ blocked: true, remaining: 0, expires: expect.any(String) }) config.key = '192.169.2.1' const res2 = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => { const res = new Request(fakeDomain, { method: 'POST', body: JSON.stringify(config) }) return await instance.fetch(res) }) const body2 = await res2.json() expect(res2.status).toBe(httpStatus.OK) expect(body2).toEqual({ blocked: false, remaining: 199, expires: expect.any(String) }) }) test('should return 200 and not rate limit if window expired', async () => { const config = { scope: '/v1/auth/send-verification-email', key, limit: 200, interval: 600 } const currentWindow = Math.floor(dayjs().unix() / config.interval) const storageKey = `${config.scope}|${config.key.toString()}|${config.limit}|` + `${config.interval}|${currentWindow}` const rateLimiter = env.RATE_LIMITER.get(id) await runInDurableObject(rateLimiter, async (_, state) => { await state.storage.put(storageKey, config.limit) }) const res = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => { const res = new Request(fakeDomain, { method: 'POST', body: JSON.stringify(config) }) return await instance.fetch(res) }) const body = await res.json() expect(res.status).toBe(httpStatus.OK) expect(body).toEqual({ blocked: true, remaining: 0, expires: expect.any(String) }) const expires = dayjs(res.headers.get('expires')) MockDate.set(expires.add(1, 'second').toDate()) const res2 = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => { const res = new Request(fakeDomain, { method: 'POST', body: JSON.stringify(config) }) return await instance.fetch(res) }) const body2 = await res2.json() expect(res2.status).toBe(httpStatus.OK) expect(body2).toEqual({ blocked: false, remaining: 0, expires: expect.any(String) }) }) test('should return 200 and rate limit if just before window expiry', async () => { const config = { scope: '/v1/auth/send-verification-email', key, limit: 200, interval: 600 } const currentWindow = Math.floor(dayjs().unix() / config.interval) const storageKey = `${config.scope}|${config.key.toString()}|${config.limit}|` + `${config.interval}|${currentWindow}` const rateLimiter = env.RATE_LIMITER.get(id) await runInDurableObject(rateLimiter, async (_, state) => { await state.storage.put(storageKey, config.limit + 1) }) const res = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => { const res = new Request(fakeDomain, { method: 'POST', body: JSON.stringify(config) }) return await instance.fetch(res) }) const body = await res.json() expect(res.status).toBe(httpStatus.OK) expect(body).toEqual({ blocked: true, remaining: 0, expires: expect.any(String) }) const expires = dayjs(res.headers.get('expires')).subtract(1, 'second') MockDate.set(expires.toDate()) const res2 = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => { const res = new Request(fakeDomain, { method: 'POST', body: JSON.stringify(config) }) return await instance.fetch(res) }) const body2 = await res2.json() expect(res2.status).toBe(httpStatus.OK) expect(body2).toEqual({ blocked: true, remaining: 0, expires: expect.any(String) }) }) test('should return 400 if config is invalid', async () => { const config = { key, limit: 1, interval: 60 } expect(true).toBe(true) const rateLimiter = env.RATE_LIMITER.get(id) const res = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => { const res = new Request(fakeDomain, { method: 'POST', body: JSON.stringify(config) }) return await instance.fetch(res) }) const _ = await res.json() // https://github.com/cloudflare/workers-sdk/issues/5629 expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 if limit is not an integer', async () => { const config = { scope: '/v1/auth/send-verification-email', key, limit: 'hi', interval: 60 } const rateLimiter = env.RATE_LIMITER.get(id) const res = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => { const res = new Request(fakeDomain, { method: 'POST', body: JSON.stringify(config) }) return await instance.fetch(res) }) const _ = await res.json() // https://github.com/cloudflare/workers-sdk/issues/5629 expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 if interval is not an integer', async () => { const config = { scope: '/v1/auth/send-verification-email', key, limit: 1, interval: 'hiiam interval' } const rateLimiter = env.RATE_LIMITER.get(id) const res = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => { const res = new Request(fakeDomain, { method: 'POST', body: JSON.stringify(config) }) return await instance.fetch(res) }) const _ = await res.json() // https://github.com/cloudflare/workers-sdk/issues/5629 expect(res.status).toBe(httpStatus.BAD_REQUEST) }) }) describe('Alarm', () => { beforeEach(async () => { MockDate.reset() }) test('should expire key after 2 intervals have passed', async () => { const doConfig = { scope: '/v1/auth/send-verification-email', key, limit: 1, interval: 60 } const rateLimiter = env.RATE_LIMITER.get(id) const currentWindow = Math.floor(dayjs().unix() / doConfig.interval) const storageKey = `${doConfig.scope}|${doConfig.key.toString()}|${doConfig.limit}|` + `${doConfig.interval}|${currentWindow}` const res = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => { const res = new Request(fakeDomain, { method: 'POST', body: JSON.stringify(doConfig) }) return await instance.fetch(res) }) const _ = await res.json() // https://github.com/cloudflare/workers-sdk/issues/5629 expect(res.status).toBe(httpStatus.OK) const values = await runInDurableObject(rateLimiter, async (_, state) => { return await state.storage.list() }) expect(values.size).toBe(1) expect(values.get(storageKey)).toBe(1) MockDate.set( dayjs() .add(doConfig.interval * 3, 'seconds') .toDate() ) await runInDurableObject(rateLimiter, async (_, state) => { await state.storage.put(storageKey, doConfig.limit + 1) }) await runDurableObjectAlarm(rateLimiter) const values2 = await runInDurableObject(rateLimiter, async (_, state) => { return await state.storage.list() }) expect(values2.size).toBe(0) }) test('should not expire key if within 2 intervals', async () => { const config = { scope: '/v1/auth/send-verification-email', key, limit: 1, interval: 60 } const rateLimiter = env.RATE_LIMITER.get(id) const currentWindow = Math.floor(dayjs().unix() / config.interval) const storageKey = `${config.scope}|${config.key.toString()}|${config.limit}|` + `${config.interval}|${currentWindow}` const res = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => { const res = new Request(fakeDomain, { method: 'POST', body: JSON.stringify(config) }) return await instance.fetch(res) }) const _ = await res.json() // https://github.com/cloudflare/workers-sdk/issues/5629 expect(res.status).toBe(httpStatus.OK) const values = await runInDurableObject(rateLimiter, async (_, state) => { return await state.storage.list() }) expect(values.size).toBe(1) expect(values.get(storageKey)).toBe(1) MockDate.set( dayjs() .add(config.interval * 1.5, 'seconds') .toDate() ) await runDurableObjectAlarm(rateLimiter) const values2 = await runInDurableObject(rateLimiter, async (_, state) => { return await state.storage.list() }) expect(values2.size).toBe(1) expect(values2.get(storageKey)).toBe(1) }) test('should expire keys that are more than 2 intervals old and keep the others', async () => { const config = { scope: '/v1/auth/send-verification-email', key, limit: 1, interval: 60 } const rateLimiter = env.RATE_LIMITER.get(id) const currentWindow = Math.floor(dayjs().unix() / config.interval) const storageKey = `${config.scope}|${config.key.toString()}|${config.limit}|` + `${config.interval}|${currentWindow}` const res = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => { const res = new Request(fakeDomain, { method: 'POST', body: JSON.stringify(config) }) return await instance.fetch(res) }) const _ = await res.json() // https://github.com/cloudflare/workers-sdk/issues/5629 expect(res.status).toBe(httpStatus.OK) const expiredWindow = Math.floor(dayjs().unix() / config.interval - 3) const expiredStorageKey = `${config.scope}|${config.key.toString()}|${config.limit}|` + `${config.interval}|${expiredWindow}` await runInDurableObject(rateLimiter, async (_, state) => { await state.storage.put(expiredStorageKey, 45) }) const expiredWindow2 = Math.floor(dayjs().unix() / config.interval - 7) const expiredStorageKey2 = `${config.scope}|${config.key.toString()}|${config.limit}|` + `${config.interval}|${expiredWindow2}` await runInDurableObject(rateLimiter, async (_, state) => { await state.storage.put(expiredStorageKey2, 33) }) const expiredWindow3 = Math.floor(dayjs().unix() / config.interval - 4) const expiredStorageKey3 = `${config.scope}|${config.key.toString()}|${config.limit}|` + `${config.interval}|${expiredWindow3}` await runInDurableObject(rateLimiter, async (_, state) => { await state.storage.put(expiredStorageKey3, 12) }) const window2 = Math.floor(dayjs().unix() / config.interval - 1.5) const storageKey2 = `${config.scope}|${config.key.toString()}|${config.limit}|` + `${config.interval}|${window2}` await runInDurableObject(rateLimiter, async (_, state) => { await state.storage.put(storageKey2, 12) }) const values = await runInDurableObject(rateLimiter, async (_, state) => { return await state.storage.list() }) expect(values.size).toBe(5) await runDurableObjectAlarm(rateLimiter) const values2 = await runInDurableObject(rateLimiter, async (_, state) => { return await state.storage.list() }) expect(values2.size).toBe(2) expect(values2.get(expiredStorageKey)).toBeUndefined() expect(values2.get(expiredStorageKey2)).toBeUndefined() expect(values2.get(expiredStorageKey3)).toBeUndefined() expect(values2.get(storageKey)).toBe(1) expect(values2.get(storageKey2)).toBe(12) }) }) }) ================================================ FILE: tests/integration/user.test.ts ================================================ import { faker } from '@faker-js/faker' import { env } from 'cloudflare:test' import httpStatus from 'http-status' import { test, describe, expect, beforeEach } from 'vitest' import { getConfig } from '../../src/config/config' import { getDBClient } from '../../src/config/database' import { tokenTypes } from '../../src/config/tokens' import { CreateUser } from '../../src/validations/user.validation' import { getAccessToken } from '../fixtures/token.fixture' import { UserResponse } from '../fixtures/user.fixture' import { userOne, userTwo, admin, insertUsers } from '../fixtures/user.fixture' import { clearDBTables } from '../utils/clear-db-tables' import { request } from '../utils/test-request' const config = getConfig(env) const client = getDBClient(config.database) clearDBTables(['user'], config.database) describe('User routes', () => { describe('POST /v1/users', () => { let newUser: CreateUser beforeEach(() => { newUser = { name: faker.person.fullName(), email: faker.internet.email().toLowerCase(), password: 'password1', role: 'user', is_email_verified: false } }) test('should return 201 and successfully create new user if data is ok', async () => { await insertUsers([admin], config.database) const adminAccessToken = await getAccessToken(admin.id, admin.role, config.jwt) const res = await request('/v1/users', { method: 'POST', body: JSON.stringify(newUser), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${adminAccessToken}` } }) const body = await res.json() expect(res.status).toBe(httpStatus.CREATED) expect(body).not.toHaveProperty('password') expect(body).toEqual({ id: expect.any(String), name: newUser.name, email: newUser.email, role: 'user', is_email_verified: 0 }) const dbUser = await client .selectFrom('user') .selectAll() .where('user.id', '=', body.id) .executeTakeFirst() expect(dbUser).toBeDefined() if (!dbUser) return expect(dbUser.password).not.toBe(newUser.password) expect(dbUser).toEqual({ id: body.id, name: newUser.name, password: expect.anything(), email: newUser.email, role: 'user', is_email_verified: 0, created_at: expect.any(Date), updated_at: expect.any(Date) }) }) test('should be able to create an admin as well', async () => { await insertUsers([admin], config.database) newUser.role = 'admin' const adminAccessToken = await getAccessToken(admin.id, admin.role, config.jwt) const res = await request('/v1/users', { method: 'POST', body: JSON.stringify(newUser), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${adminAccessToken}` } }) const body = await res.json() expect(res.status).toBe(httpStatus.CREATED) expect(body.role).toBe('admin') const dbUser = await client .selectFrom('user') .selectAll() .where('user.id', '=', body.id) .executeTakeFirst() expect(dbUser).toBeDefined() if (!dbUser) return expect(dbUser.password).not.toBe(newUser.password) expect(dbUser.role).toBe('admin') }) test('should return 401 error if access token is missing', async () => { const res = await request('/v1/users', { method: 'POST', body: JSON.stringify(newUser), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) }) test('should return 403 error if logged in user is not admin', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const res = await request('/v1/users', { method: 'POST', body: JSON.stringify(newUser), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.FORBIDDEN) }) test('should return 400 error if email is invalid', async () => { await insertUsers([admin], config.database) newUser.email = 'invalidEmail' const adminAccessToken = await getAccessToken(admin.id, admin.role, config.jwt) const res = await request('/v1/users', { method: 'POST', body: JSON.stringify(newUser), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${adminAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 error if email is already used', async () => { await insertUsers([admin, userOne], config.database) newUser.email = userOne.email const adminAccessToken = await getAccessToken(admin.id, admin.role, config.jwt) const res = await request('/v1/users', { method: 'POST', body: JSON.stringify(newUser), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${adminAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 error if password length is less than 8 characters', async () => { await insertUsers([admin], config.database) newUser.password = 'passwo1' const adminAccessToken = await getAccessToken(admin.id, admin.role, config.jwt) const res = await request('/v1/users', { method: 'POST', body: JSON.stringify(newUser), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${adminAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 if password does not contain both letters and numbers', async () => { await insertUsers([admin], config.database) newUser.password = 'password' const adminAccessToken = await getAccessToken(admin.id, admin.role, config.jwt) const res = await request('/v1/users', { method: 'POST', body: JSON.stringify(newUser), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${adminAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) newUser.password = '1111111' const res2 = await request('/v1/users', { method: 'POST', body: JSON.stringify(newUser), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${adminAccessToken}` } }) expect(res2.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 error if role is neither user nor admin', async () => { await insertUsers([admin], config.database) const adminAccessToken = await getAccessToken(admin.id, admin.role, config.jwt) const res = await request('/v1/users', { method: 'POST', body: JSON.stringify({ ...newUser, role: 'invalid' }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${adminAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 201 and is_email_verified false if set to true', async () => { await insertUsers([admin], config.database) newUser.is_email_verified = true const adminAccessToken = await getAccessToken(admin.id, admin.role, config.jwt) const res = await request('/v1/users', { method: 'POST', body: JSON.stringify(newUser), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${adminAccessToken}` } }) expect(res.status).toBe(httpStatus.CREATED) const body = await res.json() expect(body.is_email_verified).toBe(0) }) test('should return 403 if user has not verified their email', async () => { await insertUsers([userTwo], config.database) const accessToken = await getAccessToken( userTwo.id, userTwo.role, config.jwt, tokenTypes.ACCESS, userTwo.is_email_verified ) const res = await request('/v1/users', { method: 'POST', body: JSON.stringify(newUser), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` } }) expect(res.status).toBe(httpStatus.FORBIDDEN) }) }) describe('GET /v1/users', () => { test('should return 200 and apply the default query options', async () => { await insertUsers([userOne, userTwo, admin], config.database) const adminAccessToken = await getAccessToken(admin.id, admin.role, config.jwt) const res = await request('/v1/users', { method: 'GET', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${adminAccessToken}` } }) const body = await res.json() expect(res.status).toBe(httpStatus.OK) expect(body).toHaveLength(3) expect(body).toEqual( expect.arrayContaining([ { id: userOne.id, name: userOne.name, email: userOne.email, role: userOne.role, is_email_verified: 0 }, { id: userTwo.id, name: userTwo.name, email: userTwo.email, role: userTwo.role, is_email_verified: 0 }, { id: admin.id, name: admin.name, email: admin.email, role: admin.role, is_email_verified: 0 } ]) ) }) test('should return 401 if access token is missing', async () => { await insertUsers([userOne, userTwo, admin], config.database) const res = await request('/v1/users', { method: 'GET', headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) }) test('should return 403 if a non-admin is trying to access all users', async () => { await insertUsers([userOne, userTwo, admin], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const res = await request('/v1/users', { method: 'GET', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.FORBIDDEN) }) test('should correctly apply filter on email field', async () => { await insertUsers([userOne, userTwo, admin], config.database) const adminAccessToken = await getAccessToken(admin.id, admin.role, config.jwt) const res = await request(`/v1/users?email=${userOne.email}`, { method: 'GET', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${adminAccessToken}` } }) const body = await res.json() expect(res.status).toBe(httpStatus.OK) expect(body).toHaveLength(1) expect(body[0].id).toBe(userOne.id) }) test('should limit returned array if limit param is specified', async () => { await insertUsers([userOne, userTwo, admin], config.database) const adminAccessToken = await getAccessToken(admin.id, admin.role, config.jwt) const res = await request('/v1/users?limit=2', { method: 'GET', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${adminAccessToken}` } }) const body = await res.json() expect(res.status).toBe(httpStatus.OK) expect(body).toHaveLength(2) }) test('should return the correct page if page and limit params are specified', async () => { await insertUsers([userOne, userTwo, admin], config.database) const adminAccessToken = await getAccessToken(admin.id, admin.role, config.jwt) const res = await request('/v1/users?limit=2&page=1', { method: 'GET', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${adminAccessToken}` } }) const body = await res.json() expect(res.status).toBe(httpStatus.OK) expect(body).toHaveLength(1) }) test('should return 403 if user has not verified their email', async () => { await insertUsers([userTwo], config.database) const accessToken = await getAccessToken( userTwo.id, userTwo.role, config.jwt, tokenTypes.ACCESS, userTwo.is_email_verified ) const res = await request('/v1/users?limit=2&page=1', { method: 'GET', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` } }) expect(res.status).toBe(httpStatus.FORBIDDEN) }) }) describe('GET /v1/users/:userId', () => { test('should return 200 and the user object if data is ok', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const res = await request(`/v1/users/${userOne.id}`, { method: 'GET', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) const body = await res.json() expect(res.status).toBe(httpStatus.OK) expect(body).not.toHaveProperty('password') expect(body).toEqual({ id: userOne.id, name: userOne.name, email: userOne.email, role: userOne.role, is_email_verified: 0 }) }) test('should return 401 error if access token is missing', async () => { await insertUsers([userOne], config.database) const res = await request(`/v1/users/${userOne.id}`, { method: 'GET', headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) }) test('should return 403 error if user is trying to get another user', async () => { await insertUsers([userOne, userTwo], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const res = await request(`/v1/users/${userTwo.id}`, { method: 'GET', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.FORBIDDEN) }) test('should return 200 and user if admin is trying to get another user', async () => { await insertUsers([userOne, admin], config.database) const adminAccessToken = await getAccessToken(admin.id, admin.role, config.jwt) const res = await request(`/v1/users/${userOne.id}`, { method: 'GET', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${adminAccessToken}` } }) expect(res.status).toBe(httpStatus.OK) }) test('should return 404 error if user is not found', async () => { await insertUsers([admin], config.database) const adminAccessToken = await getAccessToken(admin.id, admin.role, config.jwt) const res = await request('/v1/users/1221212', { method: 'GET', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${adminAccessToken}` } }) expect(res.status).toBe(httpStatus.NOT_FOUND) }) test('should return 403 if user has not verified their email', async () => { await insertUsers([userTwo], config.database) const accessToken = await getAccessToken( userTwo.id, userTwo.role, config.jwt, tokenTypes.ACCESS, userTwo.is_email_verified ) const res = await request('/v1/users/1221212', { method: 'GET', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` } }) expect(res.status).toBe(httpStatus.FORBIDDEN) }) }) describe('DELETE /v1/users/:userId', () => { test('should return 204 if data is ok', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const res = await request(`/v1/users/${userOne.id}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.NO_CONTENT) const dbUser = await client .selectFrom('user') .selectAll() .where('user.id', '=', userOne.id) .executeTakeFirst() expect(dbUser).toBe(undefined) }) test('should return 401 error if access token is missing', async () => { await insertUsers([userOne], config.database) const res = await request(`/v1/users/${userOne.id}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) }) test('should return 403 error if user is trying to delete another user', async () => { await insertUsers([userOne, userTwo], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const res = await request(`/v1/users/${userTwo.id}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.FORBIDDEN) }) test('should return 204 if admin is trying to delete another user', async () => { await insertUsers([userOne, admin], config.database) const adminAccessToken = await getAccessToken(admin.id, admin.role, config.jwt) const res = await request(`/v1/users/${userOne.id}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${adminAccessToken}` } }) expect(res.status).toBe(httpStatus.NO_CONTENT) }) test('should return 404 error if user already is not found', async () => { await insertUsers([admin], config.database) const adminAccessToken = await getAccessToken(admin.id, admin.role, config.jwt) const res = await request('/v1/users/12345', { method: 'DELETE', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${adminAccessToken}` } }) expect(res.status).toBe(httpStatus.NOT_FOUND) }) test('should return 403 if user has not verified their email', async () => { await insertUsers([userTwo], config.database) const accessToken = await getAccessToken( userTwo.id, userTwo.role, config.jwt, tokenTypes.ACCESS, userTwo.is_email_verified ) const res = await request('/v1/users/12345', { method: 'DELETE', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` } }) expect(res.status).toBe(httpStatus.FORBIDDEN) }) }) describe('PATCH /v1/users/:userId', () => { test('should return 200 and successfully update user if data is ok', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const updateBody = { name: faker.person.fullName(), email: faker.internet.email().toLowerCase() } const res = await request(`/v1/users/${userOne.id}`, { method: 'PATCH', body: JSON.stringify(updateBody), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) const body = await res.json() expect(res.status).toBe(httpStatus.OK) expect(body).not.toHaveProperty('password') expect(body).toEqual({ id: userOne.id, name: updateBody.name, email: updateBody.email, role: 'user', is_email_verified: 0 }) const dbUser = await client .selectFrom('user') .selectAll() .where('user.id', '=', body.id) .executeTakeFirst() expect(dbUser).toBeDefined() if (!dbUser) return expect(dbUser.password).not.toBe(userOne.password) expect(dbUser).toMatchObject({ name: updateBody.name, password: expect.anything(), email: updateBody.email, role: 'user', is_email_verified: 0 }) }) test('should return 401 error if access token is missing', async () => { await insertUsers([userOne], config.database) const updateBody = { name: faker.person.fullName() } const res = await request(`/v1/users/${userOne.id}`, { method: 'PATCH', body: JSON.stringify(updateBody), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(httpStatus.UNAUTHORIZED) }) test('should return 403 if user is updating another user', async () => { await insertUsers([userOne, userTwo], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const updateBody = { name: faker.person.fullName() } const res = await request(`/v1/users/${userTwo.id}`, { method: 'PATCH', body: JSON.stringify(updateBody), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.FORBIDDEN) }) test('should return 200 and update user if admin is updating another user', async () => { await insertUsers([userOne, admin], config.database) const adminAccessToken = await getAccessToken(admin.id, admin.role, config.jwt) const updateBody = { name: faker.person.fullName() } const res = await request(`/v1/users/${userOne.id}`, { method: 'PATCH', body: JSON.stringify(updateBody), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${adminAccessToken}` } }) expect(res.status).toBe(httpStatus.OK) }) test('should return 404 if admin is updating another user that is not found', async () => { await insertUsers([admin], config.database) const adminAccessToken = await getAccessToken(admin.id, admin.role, config.jwt) const updateBody = { name: faker.person.fullName() } const res = await request('/v1/users/123123222', { method: 'PATCH', body: JSON.stringify(updateBody), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${adminAccessToken}` } }) expect(res.status).toBe(httpStatus.NOT_FOUND) }) test('should return 400 if email is invalid', async () => { await insertUsers([userOne, admin], config.database) const adminAccessToken = await getAccessToken(admin.id, admin.role, config.jwt) const updateBody = { email: 'invalidEmail' } const res = await request(`/v1/users/${userOne.id}`, { method: 'PATCH', body: JSON.stringify(updateBody), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${adminAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 400 if email is already taken', async () => { await insertUsers([userOne, userTwo], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const updateBody = { email: userTwo.email } const res = await request(`/v1/users/${userOne.id}`, { method: 'PATCH', body: JSON.stringify(updateBody), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should not return 400 if email is my email', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const updateBody = { email: userOne.email } const res = await request(`/v1/users/${userOne.id}`, { method: 'PATCH', body: JSON.stringify(updateBody), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.OK) }) test('should return 400 if one of email/password/role are not passed in', async () => { await insertUsers([userOne], config.database) const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) const updateBody = {} const res = await request(`/v1/users/${userOne.id}`, { method: 'PATCH', body: JSON.stringify(updateBody), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userOneAccessToken}` } }) expect(res.status).toBe(httpStatus.BAD_REQUEST) }) test('should return 403 if user has not verified their email', async () => { await insertUsers([userTwo], config.database) const accessToken = await getAccessToken( userTwo.id, userTwo.role, config.jwt, tokenTypes.ACCESS, userTwo.is_email_verified ) const updateBody = {} const res = await request('/v1/users/1234', { method: 'PATCH', body: JSON.stringify(updateBody), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` } }) expect(res.status).toBe(httpStatus.FORBIDDEN) }) }) }) ================================================ FILE: tests/mocks/awsClientStub/aws-client-stub.ts ================================================ import { Client, Command, MetadataBearer } from '@smithy/types' import { MockInstance, vi, Mock } from 'vitest' import { mockClient } from './mock-client' export type AwsClientBehavior = TClient extends Client ? Behavior : never export interface Behavior< TInput extends object, TOutput extends MetadataBearer, TCommandOutput extends TOutput, TConfiguration > { on( command: new (input: TCmdInput) => AwsCommand, input?: Partial, strict?: boolean ): Behavior resolves(response: CommandResponse): AwsStub rejects(error?: string | Error | AwsError): AwsStub } /** * Type for {@link AwsStub} class, * but with the AWS Client class type as an only generic parameter. * * @example * ```ts * let snsMock: AwsClientStub; * snsMock = mockClient(SNSClient); * ``` */ export type AwsClientStub = TClient extends Client ? AwsStub : never type MockCall = { args: In result: MockResult } type MockResult = | { type: 'return' value: T } | { type: 'throw' value: unknown } type Inputs = Parameters< Client['send'] > type Output = ReturnType< Client['send'] > /** * Wrapper on the mocked `Client#send()` method, * allowing to configure its behavior. * * Without any configuration, `Client#send()` invocation returns `undefined`. * * To define resulting variable type easily, use {@link AwsClientStub}. */ export class AwsStub { /** * Underlying `Client#send()` method Sinon stub. * * Install `@types/sinon` for TypeScript typings. */ public send: MockInstance< Inputs, Output > constructor( private client: Client, send: MockInstance< Inputs, Output > ) { this.send = send } /** Returns the class name of the underlying mocked client class */ clientName(): string { return this.client.constructor.name } /** * Resets stub. It will replace the stub with a new one, with clean history and behavior. */ reset(): AwsStub { /* sinon.stub.reset() does not remove the fakes which in some conditions can break subsequent stubs, * so instead of calling send.reset(), we recreate the stub. * See: https://github.com/sinonjs/sinon/issues/1572 * We are only affected by the broken reset() behavior of this bug, since we always use matchers. */ const newStub = mockClient(this.client) this.send = newStub.send return this } /** Replaces stub with original `Client#send()` method. */ restore(): void { this.send.mockRestore() } /** * Returns recorded calls to the stub. */ calls(): MockCall< Inputs, Output >[] { return this.send.mock.calls.map( (call, i) => ({ args: call, result: this.send.mock.results[i] }) as MockCall< Inputs, Output > ) } /** * Returns n-th recorded call to the stub. */ call( n: number ): MockCall, Output> { return this.calls()[n] } /** * Allows specifying the behavior for a given Command type and its input (parameters). * * If the input is not specified, it will match any Command of that type. * * @example * ```js * snsMock * .on(PublishCommand, {Message: 'My message'}) * .resolves({MessageId: '111'}); * ``` * * @param command Command type to match * @param input Command payload to match * @param strict Should the payload match strictly (default false, will match if all defined payload properties match) */ on( command: new (input: TCmdInput) => AwsCommand ): CommandBehavior { const cmdStub: Mock< Inputs, Output > = vi.fn((cmd, opts, cb) => { return this.client.send(cmd, opts, cb) }) this.send.mockImplementation((cmd, opts, cb) => { if (cmd instanceof command) return cmdStub(cmd, opts, cb) return this.client.send(cmd, opts, cb) }) return new CommandBehavior(this, cmdStub) } } export class CommandBehavior< TInput extends object, TOutput extends MetadataBearer, TCommandOutput extends TOutput, TConfiguration > { constructor( private clientStub: AwsStub, private send: Mock< Inputs, Output > ) {} /** * Sets a successful response that will be returned from `Client#send()` invocation for the current `Command`. * * @example * ```js * snsMock * .on(PublishCommand) * .resolves({MessageId: '111'}); * ``` * * @param response Content to be returned */ resolves( response: Awaited> ): AwsStub { this.send.mockImplementation(() => Promise.resolve(response) as unknown as Promise) return this.clientStub } /** * Sets a failure response that will be returned from `Client#send()` invocation for the current `Command`. * The response will always be an `Error` instance. * * @example * ```js * snsMock * .on(PublishCommand) * .rejects('mocked rejection'); *``` * * @example * ```js * const throttlingError = new Error('mocked rejection'); * throttlingError.name = 'ThrottlingException'; * snsMock * .on(PublishCommand) * .rejects(throttlingError); * ``` * * @param error Error text, Error instance or Error parameters to be returned */ rejects(error?: string | Error | AwsError): AwsStub { this.send.mockImplementation(() => Promise.reject(error)) return this.clientStub } } export type AwsCommand< Input extends ClientInput, Output extends ClientOutput, ClientInput extends object, ClientOutput extends MetadataBearer > = Command type CommandResponse = Partial | PromiseLike> export interface AwsError extends Partial, Partial { Type?: string Code?: string $fault?: 'client' | 'server' $service?: string } ================================================ FILE: tests/mocks/awsClientStub/expect-mock.ts ================================================ import { AwsStub } from './aws-client-stub' // eslint-disable-next-line export function toHaveReceivedCommandTimes(mock: AwsStub, command: unknown, times: number) { const calls = mock.send.mock.calls // eslint-disable-next-line .filter((call) => call[0] instanceof (command as any)) .length return { pass: calls === times, message: () => `Function was called ${calls} times with input, expected ${times} calls` } } export const expectExtension = { toHaveReceivedCommandTimes } interface CustomMatchers { // eslint-disable-next-line toHaveReceivedCommandTimes: (command: unknown, times: number) => R } declare module 'vitest' { // eslint-disable-next-line interface Assertion extends CustomMatchers {} // eslint-disable-next-line interface AsymmetricMatchersContaining extends CustomMatchers {} } ================================================ FILE: tests/mocks/awsClientStub/index.ts ================================================ export * from './mock-client' export * from './aws-client-stub' export * from './expect-mock' ================================================ FILE: tests/mocks/awsClientStub/mock-client.ts ================================================ import { Client, MetadataBearer } from '@smithy/types' import { vi } from 'vitest' import { AwsClientStub, AwsStub } from './aws-client-stub' /** * Creates and attaches a stub of the `Client#send()` method. Only this single method is mocked. * If method is already a stub, it's replaced. * @param client `Client` type or instance to replace the method * @param sandbox Optional sinon sandbox to use * @return Stub allowing to configure Client's behavior */ export const mockClient = ( client: InstanceOrClassType> ): AwsClientStub> => { const instance = isClientInstance(client) ? client : client.prototype // const send = instance.send; // if (vi.isMockFunction(send)) { // send.restore(); // } const sendStub = vi.spyOn(instance, 'send') return new AwsStub(instance, sendStub) } type ClassType = { prototype: T } type InstanceOrClassType = T | ClassType /** * Type guard to differentiate `Client` instance from a type. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any const isClientInstance = >( obj: InstanceOrClassType ): obj is TClient => (obj as TClient).send !== undefined ================================================ FILE: tests/tsconfig.json ================================================ { "extends": "../tsconfig.json", "compilerOptions": { "moduleResolution": "bundler", "types": [ "@types/bcryptjs", "@cloudflare/workers-types/experimental", "@cloudflare/vitest-pool-workers", "vitest" ] }, "include": ["../src/**/*", "**/*", "../bindings.d.ts", "cloudflare-test.d.ts", "vitest.d.ts"] } ================================================ FILE: tests/utils/clear-db-tables.ts ================================================ import { beforeEach } from 'vitest' import { Config } from '../../src/config/config' import { getDBClient, Database } from '../../src/config/database' const clearDBTables = (tables: Array, databaseConfig: Config['database']) => { const client = getDBClient(databaseConfig) beforeEach(async () => { for (const table of tables) { await client.deleteFrom(table).executeTakeFirst() } }) } export { clearDBTables } ================================================ FILE: tests/utils/test-request.ts ================================================ import { env } from 'cloudflare:test' import app from '../../src' import '../../src/routes' const devUrl = 'http://localhost' class Context implements ExecutionContext { passThroughOnException(): void { throw new Error('Method not implemented.') } abort(): void {} // eslint-disable-next-line @typescript-eslint/no-explicit-any async waitUntil(promise: Promise): Promise { await promise } } const request = async (path: string, options: RequestInit) => { const formattedUrl = new URL(path, devUrl).href const request = new Request(formattedUrl, options) return app.fetch(request, env, new Context()) } export { request } ================================================ FILE: tests/vitest.d.ts ================================================ /* eslint-disable @typescript-eslint/no-empty-object-type */ import 'vitest' import { CustomMatcher } from 'aws-sdk-client-mock-vitest' declare module 'vitest' { // eslint-disable-next-line @typescript-eslint/no-explicit-any interface Assertion extends CustomMatcher {} interface AsymmetricMatchersContaining extends CustomMatcher {} } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "allowJs": true, "target": "esnext", "lib": ["esnext"], "moduleResolution": "bundler", "resolveJsonModule": true, "inlineSourceMap": true, "module": "esnext", "esModuleInterop": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, "strict": true, "noImplicitAny": true, "noEmit": true, "skipLibCheck": true }, "ts-node": { "transpileOnly": true }, "include": ["./src/**/*", "bindings.d.ts"] } ================================================ FILE: vitest.config.ts ================================================ import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config' export default defineWorkersConfig({ test: { poolOptions: { workers: { wrangler: { configPath: 'wrangler.toml', environment: 'test' }, isolatedStorage: true, singleWorker: true } } } }) ================================================ FILE: wrangler.toml.example ================================================ name = 'cf-workers-hono-planetscale-app' main = 'dist/index.mjs' workers_dev = true compatibility_date = '2024-08-23' compatability_flags = [2nodejs_compat'] account_id='' [durable_objects] bindings = [ { name = 'RATE_LIMITER', class_name = 'RateLimiter' } ] [env.test.durable_objects] bindings = [ { name = 'RATE_LIMITER', class_name = 'RateLimiter' } ] [[migrations]] tag = 'v1' new_classes = ['RateLimiter'] [[env.test.migrations]] tag = 'v1' new_classes = ['RateLimiter'] [env.test.vars] ENV = 'development' JWT_ACCESS_EXPIRATION_MINUTES=30 JWT_REFRESH_EXPIRATION_DAYS=30 JWT_RESET_PASSWORD_EXPIRATION_MINUTES=15 JWT_VERIFY_EMAIL_EXPIRATION_MINUTES=15 DATABASE_NAME='example' DATABASE_USERNAME='example' DATABASE_HOST='example' AWS_REGION='eu-west-1' EMAIL_SENDER='noreply@gmail.com' OAUTH_GITHUB_CLIENT_ID='myclientid' OAUTH_DISCORD_CLIENT_ID='myclientid' OAUTH_DISCORD_REDIRECT_URL='https://frontend.com/login' OAUTH_SPOTIFY_CLIENT_ID='myclientid' OAUTH_SPOTIFY_REDIRECT_URL='https://frontend.com/login' OAUTH_GOOGLE_CLIENT_ID='myclientid' OAUTH_GOOGLE_REDIRECT_URL='https://frontend.com/login' OAUTH_FACEBOOK_CLIENT_ID='myclientid' OAUTH_FACEBOOK_REDIRECT_URL='https://frontend.com/login' OAUTH_APPLE_CLIENT_ID='com.your.app' OAUTH_APPLE_KEY_ID='randomid' OAUTH_APPLE_TEAM_ID='randomid' OAUTH_APPLE_JWT_ACCESS_EXPIRATION_MINUTES=30 OAUTH_APPLE_REDIRECT_URL='https://api.com/v1/auth/apple/callback' [vars] ENV = 'development' JWT_ACCESS_EXPIRATION_MINUTES=30 JWT_REFRESH_EXPIRATION_DAYS=30 JWT_RESET_PASSWORD_EXPIRATION_MINUTES=15 JWT_VERIFY_EMAIL_EXPIRATION_MINUTES=15 DATABASE_NAME='example' DATABASE_USERNAME='example' DATABASE_HOST='example' AWS_REGION='eu-west-1' EMAIL_SENDER='noreply@gmail.com' OAUTH_GITHUB_CLIENT_ID='myclientid' OAUTH_DISCORD_CLIENT_ID='myclientid' OAUTH_DISCORD_REDIRECT_URL='https://frontend.com/login' OAUTH_SPOTIFY_CLIENT_ID='myclientid' OAUTH_SPOTIFY_REDIRECT_URL='https://frontend.com/login' OAUTH_GOOGLE_CLIENT_ID='myclientid' OAUTH_GOOGLE_REDIRECT_URL='https://frontend.com/login' OAUTH_FACEBOOK_CLIENT_ID='myclientid' OAUTH_FACEBOOK_REDIRECT_URL='https://frontend.com/login' OAUTH_APPLE_CLIENT_ID='com.your.app' OAUTH_APPLE_KEY_ID='randomid' OAUTH_APPLE_TEAM_ID='randomid' OAUTH_APPLE_JWT_ACCESS_EXPIRATION_MINUTES=30 OAUTH_APPLE_REDIRECT_URL='https://api.com/v1/auth/apple/callback' [build] command = 'npm run build' # [secrets] # JWT_SECRET # DATABASE_PASSWORD # AWS_ACCESS_KEY_ID # AWS_SECRET_ACCESS_KEY # SENTRY_DSN # OAUTH_GITHUB_CLIENT_SECRET # OAUTH_DISCORD_CLIENT_SECRET # OAUTH_SPOTIFY_CLIENT_SECRET # OAUTH_GOOGLE_CLIENT_SECRET # OAUTH_FACEBOOK_CLIENT_SECRET # OAUTH_APPLE_PRIVATE_KEY