├── .husky ├── pre-commit └── pre-push ├── .eslintignore ├── .prettierignore ├── src ├── utils │ ├── utils.ts │ ├── zod.ts │ └── api-error.ts ├── tables │ ├── oauth.table.ts │ ├── user.table.ts │ └── one-time-oauth-code.table.ts ├── validations │ ├── custom.type.validation.ts │ ├── custom.transform.validation.ts │ ├── custom.refine.validation.ts │ ├── user.validation.ts │ └── auth.validation.ts ├── models │ ├── token.model.ts │ ├── oauth │ │ ├── google-user.model.ts │ │ ├── discord-user.model.ts │ │ ├── github-user.model.ts │ │ ├── spotify-user.model.ts │ │ ├── facebook-user.model.ts │ │ ├── apple-user.model.ts │ │ └── oauth-base.model.ts │ ├── base.model.ts │ ├── user.model.ts │ └── one-time-oauth-code.ts ├── config │ ├── authProviders.ts │ ├── tokens.ts │ ├── roles.ts │ ├── database.ts │ └── config.ts ├── routes │ ├── index.ts │ ├── user.route.ts │ └── auth.route.ts ├── factories │ └── oauth.factory.ts ├── index.ts ├── services │ ├── oauth │ │ ├── apple.service.ts │ │ ├── spotify.service.ts │ │ ├── facebook.service.ts │ │ └── github.service.ts │ ├── email.service.ts │ ├── token.service.ts │ ├── auth.service.ts │ └── user.service.ts ├── types │ └── oauth.types.ts ├── controllers │ ├── user.controller.ts │ └── auth │ │ ├── oauth │ │ ├── github.controller.ts │ │ ├── facebook.controller.ts │ │ ├── google.controller.ts │ │ ├── discord.controller.ts │ │ ├── spotify.controller.ts │ │ ├── oauth.controller.ts │ │ └── apple.controller.ts │ │ └── auth.controller.ts ├── middlewares │ ├── error.ts │ ├── auth.ts │ └── rate-limiter.ts └── durable-objects │ └── rate-limiter.do.ts ├── tests ├── mocks │ └── awsClientStub │ │ ├── index.ts │ │ ├── expect-mock.ts │ │ ├── mock-client.ts │ │ └── aws-client-stub.ts ├── cloudflare-test.d.ts ├── vitest.d.ts ├── tsconfig.json ├── integration │ ├── index.test.ts │ ├── rate-limiter.test.ts │ └── auth │ │ └── oauth │ │ └── github.test.ts ├── utils │ ├── clear-db-tables.ts │ └── test-request.ts └── fixtures │ ├── token.fixture.ts │ ├── user.fixture.ts │ └── authorisations.fixture.ts ├── .prettierrc ├── .gitignore ├── TODO.md ├── .env.example ├── vitest.config.ts ├── tsconfig.json ├── jest.config.js ├── LICENSE ├── .env.test.example ├── bindings.d.ts ├── .dev.vars.example ├── eslint.config.js ├── bin └── createApp.js ├── migrations └── 01_initial.ts ├── wrangler.toml.example ├── scripts └── migrate.ts ├── package.json └── README.md /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm run lint 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | npm run tests:coverage 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | import { nanoid } from 'nanoid' 2 | 3 | export const generateId = () => { 4 | return nanoid() 5 | } 6 | -------------------------------------------------------------------------------- /tests/mocks/awsClientStub/index.ts: -------------------------------------------------------------------------------- 1 | export * from './mock-client' 2 | export * from './aws-client-stub' 3 | export * from './expect-mock' 4 | -------------------------------------------------------------------------------- /src/tables/oauth.table.ts: -------------------------------------------------------------------------------- 1 | export interface AuthProviderTable { 2 | provider_user_id: string 3 | provider_type: string 4 | user_id: string 5 | } 6 | -------------------------------------------------------------------------------- /src/validations/custom.type.validation.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const roleZodType = z.union([z.literal('admin'), z.literal('user')]) 4 | -------------------------------------------------------------------------------- /tests/cloudflare-test.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'cloudflare:test' { 2 | import { Environment } from '../bindings' 3 | type ProvidedEnv = Environment 4 | } 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false, 4 | "trailingComma": "none", 5 | "tabWidth": 2, 6 | "printWidth": 100, 7 | "bracketSpacing": true 8 | } 9 | -------------------------------------------------------------------------------- /src/models/token.model.ts: -------------------------------------------------------------------------------- 1 | export interface TokenResponse { 2 | access: { 3 | token: string 4 | expires: Date 5 | } 6 | refresh: { 7 | token: string 8 | expires: Date 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/config/authProviders.ts: -------------------------------------------------------------------------------- 1 | export const authProviders = { 2 | GITHUB: 'github', 3 | SPOTIFY: 'spotify', 4 | DISCORD: 'discord', 5 | GOOGLE: 'google', 6 | FACEBOOK: 'facebook', 7 | APPLE: 'apple' 8 | } as const 9 | -------------------------------------------------------------------------------- /src/utils/zod.ts: -------------------------------------------------------------------------------- 1 | import { ZodError } from 'zod' 2 | import { fromError } from 'zod-validation-error' 3 | 4 | export const generateZodErrorMessage = (error: ZodError): string => { 5 | return fromError(error).message 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /dist 3 | **/*.rs.bk 4 | pkg/ 5 | wasm-pack.log 6 | worker/ 7 | node_modules/ 8 | .vscode 9 | .env* 10 | !*.example 11 | .mf 12 | wrangler.toml 13 | coverage 14 | .vscode 15 | pnpm-lock.yaml 16 | .dev.vars 17 | -------------------------------------------------------------------------------- /src/validations/custom.transform.validation.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcryptjs' 2 | 3 | export const hashPassword = async (value: string): Promise => { 4 | const hashedPassword = await bcrypt.hash(value, 8) 5 | return hashedPassword 6 | } 7 | -------------------------------------------------------------------------------- /src/config/tokens.ts: -------------------------------------------------------------------------------- 1 | export const tokenTypes = { 2 | ACCESS: 'access', 3 | REFRESH: 'refresh', 4 | RESET_PASSWORD: 'resetPassword', 5 | VERIFY_EMAIL: 'verifyEmail' 6 | } as const 7 | 8 | export type TokenType = (typeof tokenTypes)[keyof typeof tokenTypes] 9 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | ### Todo 2 | 3 | - [ ] Flesh out README 4 | - [ ] Oauth 5 | - [ ] Add support for Twitter 6 | - [ ] API docs 7 | - [ ] Fix all types 8 | - [ ] CI/CD using Github Actions 9 | - [ ] MFA 10 | - [ ] 100% test coverage 11 | - [ ] Unit tests 12 | 13 | ### In Progress 14 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Please note that this file is only used for running migrations for a development database 2 | # The rest of the variables for the development environment are setup in the wrangler.toml file 3 | DATABASE_NAME='name' 4 | DATABASE_USERNAME='username' 5 | DATABASE_HOST='host' 6 | DATABASE_PASSWORD='password' 7 | -------------------------------------------------------------------------------- /src/config/roles.ts: -------------------------------------------------------------------------------- 1 | export const roleRights = { 2 | user: [], 3 | admin: ['getUsers', 'manageUsers'] 4 | } as const 5 | 6 | export const roles = Object.keys(roleRights) as Role[] 7 | 8 | export type Permission = (typeof roleRights)[keyof typeof roleRights][number] 9 | export type Role = keyof typeof roleRights 10 | -------------------------------------------------------------------------------- /src/utils/api-error.ts: -------------------------------------------------------------------------------- 1 | export class ApiError extends Error { 2 | statusCode: number 3 | isOperational: boolean 4 | 5 | constructor(statusCode: number, message: string, isOperational = true) { 6 | super(message) 7 | this.statusCode = statusCode 8 | this.isOperational = isOperational 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/tables/user.table.ts: -------------------------------------------------------------------------------- 1 | import { Role } from '../config/roles' 2 | 3 | export interface UserTable { 4 | id: string 5 | name: string | null // null if not available on oauth account linking 6 | email: string 7 | password: string | null // null if user is created via OAuth 8 | is_email_verified: boolean 9 | role: Role 10 | } 11 | -------------------------------------------------------------------------------- /src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import { route as authRoute } from './auth.route' 2 | import { route as userRoute } from './user.route' 3 | 4 | const base_path = 'v1' 5 | 6 | export const defaultRoutes = [ 7 | { 8 | path: `/${base_path}/auth`, 9 | route: authRoute 10 | }, 11 | { 12 | path: `/${base_path}/users`, 13 | route: userRoute 14 | } 15 | ] 16 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config' 2 | 3 | export default defineWorkersConfig({ 4 | test: { 5 | poolOptions: { 6 | workers: { 7 | wrangler: { configPath: 'wrangler.toml', environment: 'test' }, 8 | isolatedStorage: true, 9 | singleWorker: true 10 | } 11 | } 12 | } 13 | }) 14 | -------------------------------------------------------------------------------- /src/tables/one-time-oauth-code.table.ts: -------------------------------------------------------------------------------- 1 | import { Generated } from 'kysely' 2 | 3 | export interface OneTimeOauthCodeTable { 4 | code: string 5 | user_id: string 6 | access_token: string 7 | access_token_expires_at: Date 8 | refresh_token: string 9 | refresh_token_expires_at: Date 10 | expires_at: Date 11 | created_at: Generated 12 | updated_at: Generated 13 | } 14 | -------------------------------------------------------------------------------- /tests/vitest.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-object-type */ 2 | import 'vitest' 3 | import { CustomMatcher } from 'aws-sdk-client-mock-vitest' 4 | 5 | declare module 'vitest' { 6 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 7 | interface Assertion extends CustomMatcher {} 8 | interface AsymmetricMatchersContaining extends CustomMatcher {} 9 | } 10 | -------------------------------------------------------------------------------- /tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "moduleResolution": "bundler", 5 | "types": [ 6 | "@types/bcryptjs", 7 | "@cloudflare/workers-types/experimental", 8 | "@cloudflare/vitest-pool-workers", 9 | "vitest" 10 | ] 11 | }, 12 | "include": ["../src/**/*", "**/*", "../bindings.d.ts", "cloudflare-test.d.ts", "vitest.d.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /tests/integration/index.test.ts: -------------------------------------------------------------------------------- 1 | import httpStatus from 'http-status' 2 | import { test, describe, expect } from 'vitest' 3 | import { request } from '../utils/test-request' 4 | 5 | describe('Basic routing', () => { 6 | test('should return 404 if route not found', async () => { 7 | const res = await request('/idontexist', { 8 | method: 'GET' 9 | }) 10 | expect(res.status).toBe(httpStatus.NOT_FOUND) 11 | }) 12 | }) 13 | -------------------------------------------------------------------------------- /src/models/oauth/google-user.model.ts: -------------------------------------------------------------------------------- 1 | import { authProviders } from '../../config/authProviders' 2 | import { GoogleUserType } from '../../types/oauth.types' 3 | import { OAuthUserModel } from './oauth-base.model' 4 | 5 | export class GoogleUser extends OAuthUserModel { 6 | constructor(user: GoogleUserType) { 7 | super({ 8 | providerType: authProviders.GOOGLE, 9 | _name: user.name, 10 | _id: user.id, 11 | _email: user.email 12 | }) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/models/oauth/discord-user.model.ts: -------------------------------------------------------------------------------- 1 | import { authProviders } from '../../config/authProviders' 2 | import { DiscordUserType } from '../../types/oauth.types' 3 | import { OAuthUserModel } from './oauth-base.model' 4 | 5 | export class DiscordUser extends OAuthUserModel { 6 | constructor(user: DiscordUserType) { 7 | super({ 8 | providerType: authProviders.DISCORD, 9 | _name: user.username, 10 | _id: user.id, 11 | _email: user.email 12 | }) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/models/oauth/github-user.model.ts: -------------------------------------------------------------------------------- 1 | import { authProviders } from '../../config/authProviders' 2 | import { GithubUserType } from '../../types/oauth.types' 3 | import { OAuthUserModel } from './oauth-base.model' 4 | 5 | export class GithubUser extends OAuthUserModel { 6 | constructor(user: GithubUserType) { 7 | super({ 8 | _id: user.id.toString(), 9 | providerType: authProviders.GITHUB, 10 | _name: user.name, 11 | _email: user.email 12 | }) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/models/oauth/spotify-user.model.ts: -------------------------------------------------------------------------------- 1 | import { authProviders } from '../../config/authProviders' 2 | import { SpotifyUserType } from '../../types/oauth.types' 3 | import { OAuthUserModel } from './oauth-base.model' 4 | 5 | export class SpotifyUser extends OAuthUserModel { 6 | constructor(user: SpotifyUserType) { 7 | super({ 8 | providerType: authProviders.SPOTIFY, 9 | _name: user.display_name, 10 | _id: user.id, 11 | _email: user.email 12 | }) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/models/oauth/facebook-user.model.ts: -------------------------------------------------------------------------------- 1 | import { authProviders } from '../../config/authProviders' 2 | import { FacebookUserType } from '../../types/oauth.types' 3 | import { OAuthUserModel } from './oauth-base.model' 4 | 5 | export class FacebookUser extends OAuthUserModel { 6 | constructor(user: FacebookUserType) { 7 | super({ 8 | providerType: authProviders.FACEBOOK, 9 | _name: `${user.first_name} ${user.last_name}`, 10 | _id: user.id, 11 | _email: user.email 12 | }) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tests/utils/clear-db-tables.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach } from 'vitest' 2 | import { Config } from '../../src/config/config' 3 | import { getDBClient, Database } from '../../src/config/database' 4 | 5 | const clearDBTables = (tables: Array, databaseConfig: Config['database']) => { 6 | const client = getDBClient(databaseConfig) 7 | beforeEach(async () => { 8 | for (const table of tables) { 9 | await client.deleteFrom(table).executeTakeFirst() 10 | } 11 | }) 12 | } 13 | 14 | export { clearDBTables } 15 | -------------------------------------------------------------------------------- /src/models/base.model.ts: -------------------------------------------------------------------------------- 1 | export abstract class BaseModel { 2 | abstract private_fields: string[] 3 | 4 | toJSON() { 5 | const properties = Object.getOwnPropertyNames(this) 6 | const publicProperties = properties.filter((property) => { 7 | return !this.private_fields.includes(property) && property !== 'private_fields' 8 | }) 9 | const json = publicProperties.reduce((obj: Record, key: string) => { 10 | obj[key] = this[key as keyof typeof this] 11 | return obj 12 | }, {}) 13 | return json 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/models/oauth/apple-user.model.ts: -------------------------------------------------------------------------------- 1 | import { authProviders } from '../../config/authProviders' 2 | import { AppleUserType } from '../../types/oauth.types' 3 | import { OAuthUserModel } from './oauth-base.model' 4 | 5 | export class AppleUser extends OAuthUserModel { 6 | constructor(user: AppleUserType) { 7 | if (!user.email) { 8 | throw new Error('Apple account must have an email linked') 9 | } 10 | super({ 11 | _id: user.sub, 12 | providerType: authProviders.APPLE, 13 | _name: user.name, 14 | _email: user.email 15 | }) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/validations/custom.refine.validation.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const password = async (value: string, ctx: z.RefinementCtx): Promise => { 4 | if (value.length < 8) { 5 | ctx.addIssue({ 6 | code: z.ZodIssueCode.custom, 7 | message: 'password must be at least 8 characters' 8 | }) 9 | return 10 | } 11 | if (!value.match(/\d/) || !value.match(/[a-zA-Z]/)) { 12 | ctx.addIssue({ 13 | code: z.ZodIssueCode.custom, 14 | message: 'password must contain at least 1 letter and 1 number' 15 | }) 16 | return 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/models/oauth/oauth-base.model.ts: -------------------------------------------------------------------------------- 1 | import { AuthProviderType, OAuthUserType } from '../../types/oauth.types' 2 | import { BaseModel } from '../base.model' 3 | 4 | export class OAuthUserModel extends BaseModel implements OAuthUserType { 5 | _id: string 6 | _email: string 7 | _name?: string 8 | providerType: AuthProviderType 9 | 10 | private_fields = [] 11 | 12 | constructor(user: OAuthUserType) { 13 | super() 14 | this._id = `${user._id}` 15 | this._email = user._email 16 | this._name = user._name || undefined 17 | this.providerType = user.providerType 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "target": "esnext", 5 | "lib": ["esnext"], 6 | "moduleResolution": "bundler", 7 | "resolveJsonModule": true, 8 | "inlineSourceMap": true, 9 | "module": "esnext", 10 | "esModuleInterop": true, 11 | "experimentalDecorators": true, 12 | "emitDecoratorMetadata": true, 13 | "strict": true, 14 | "noImplicitAny": true, 15 | "noEmit": true, 16 | "skipLibCheck": true 17 | }, 18 | "ts-node": { 19 | "transpileOnly": true 20 | }, 21 | "include": ["./src/**/*", "bindings.d.ts"] 22 | } 23 | -------------------------------------------------------------------------------- /src/routes/user.route.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono' 2 | import { Environment } from '../../bindings' 3 | import * as userController from '../controllers/user.controller' 4 | import { auth } from '../middlewares/auth' 5 | 6 | export const route = new Hono() 7 | 8 | route.post('/', auth('manageUsers'), userController.createUser) 9 | route.get('/', auth('getUsers'), userController.getUsers) 10 | 11 | route.get('/:userId', auth('getUsers'), userController.getUser) 12 | route.patch('/:userId', auth('manageUsers'), userController.updateUser) 13 | route.delete('/:userId', auth('manageUsers'), userController.deleteUser) 14 | -------------------------------------------------------------------------------- /src/factories/oauth.factory.ts: -------------------------------------------------------------------------------- 1 | import { AppleUser } from '../models/oauth/apple-user.model' 2 | import { DiscordUser } from '../models/oauth/discord-user.model' 3 | import { FacebookUser } from '../models/oauth/facebook-user.model' 4 | import { GithubUser } from '../models/oauth/github-user.model' 5 | import { GoogleUser } from '../models/oauth/google-user.model' 6 | import { SpotifyUser } from '../models/oauth/spotify-user.model' 7 | import { ProviderUserMapping } from '../types/oauth.types' 8 | 9 | export const providerUserFactory: ProviderUserMapping = { 10 | facebook: FacebookUser, 11 | discord: DiscordUser, 12 | google: GoogleUser, 13 | spotify: SpotifyUser, 14 | apple: AppleUser, 15 | github: GithubUser 16 | } 17 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { sentry } from '@hono/sentry' 2 | import { Hono } from 'hono' 3 | import { cors } from 'hono/cors' 4 | import httpStatus from 'http-status' 5 | import { Environment } from '../bindings' 6 | import { errorHandler } from './middlewares/error' 7 | import { defaultRoutes } from './routes' 8 | import { ApiError } from './utils/api-error' 9 | export { RateLimiter } from './durable-objects/rate-limiter.do' 10 | 11 | const app = new Hono() 12 | 13 | app.use('*', sentry()) 14 | app.use('*', cors()) 15 | 16 | app.notFound(() => { 17 | throw new ApiError(httpStatus.NOT_FOUND, 'Not found') 18 | }) 19 | 20 | app.onError(errorHandler) 21 | 22 | defaultRoutes.forEach((route) => { 23 | app.route(`${route.path}`, route.route) 24 | }) 25 | 26 | export default app 27 | -------------------------------------------------------------------------------- /tests/utils/test-request.ts: -------------------------------------------------------------------------------- 1 | import { env } from 'cloudflare:test' 2 | import app from '../../src' 3 | import '../../src/routes' 4 | 5 | const devUrl = 'http://localhost' 6 | 7 | class Context implements ExecutionContext { 8 | passThroughOnException(): void { 9 | throw new Error('Method not implemented.') 10 | } 11 | abort(): void {} 12 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 13 | async waitUntil(promise: Promise): Promise { 14 | await promise 15 | } 16 | } 17 | 18 | const request = async (path: string, options: RequestInit) => { 19 | const formattedUrl = new URL(path, devUrl).href 20 | const request = new Request(formattedUrl, options) 21 | return app.fetch(request, env, new Context()) 22 | } 23 | 24 | export { request } 25 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | preset: 'ts-jest/presets/default-esm', 3 | extensionsToTreatAsEsm: ['.ts'], 4 | clearMocks: true, 5 | globals: { 6 | 'ts-jest': { 7 | tsconfig: 'tests/tsconfig.json', 8 | useESM: true, 9 | isolatedModules: true, 10 | }, 11 | }, 12 | testEnvironment: 'miniflare', 13 | testEnvironmentOptions: { 14 | scriptPath: 'dist/index.mjs', 15 | modules: true 16 | }, 17 | transformIgnorePatterns: [ 18 | 'node_modules/(?!(@planetscale|kysely-planetscale|@aws-sdk|worker-auth-providers|uuid))' 19 | ], 20 | moduleNameMapper: {'^uuid$': 'uuid'}, 21 | collectCoverageFrom: ['src/**/*.{ts,js}'], 22 | coveragePathIgnorePatterns: [ 23 | 'src/durable-objects' // Jest doesn't accurately report coverage for Durable Objects 24 | ], 25 | testTimeout: 20000 26 | } 27 | -------------------------------------------------------------------------------- /tests/fixtures/token.fixture.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | import { Config } from '../../src/config/config' 3 | import { Role } from '../../src/config/roles' 4 | import { tokenTypes, TokenType } from '../../src/config/tokens' 5 | import * as tokenService from '../../src/services/token.service' 6 | 7 | export interface TokenResponse { 8 | access: { 9 | token: string 10 | expires: string 11 | } 12 | refresh: { 13 | token: string 14 | expires: string 15 | } 16 | } 17 | 18 | export const getAccessToken = async ( 19 | userId: string, 20 | role: Role, 21 | jwtConfig: Config['jwt'], 22 | type: TokenType = tokenTypes.ACCESS, 23 | isEmailVerified = true 24 | ) => { 25 | const expires = dayjs().add(jwtConfig.accessExpirationMinutes, 'minutes') 26 | const token = await tokenService.generateToken( 27 | userId, 28 | type, 29 | role, 30 | expires, 31 | jwtConfig.secret, 32 | isEmailVerified 33 | ) 34 | return token 35 | } 36 | -------------------------------------------------------------------------------- /tests/mocks/awsClientStub/expect-mock.ts: -------------------------------------------------------------------------------- 1 | import { AwsStub } from './aws-client-stub' 2 | 3 | // eslint-disable-next-line 4 | export function toHaveReceivedCommandTimes(mock: AwsStub, command: unknown, times: number) { 5 | const calls = mock.send.mock.calls 6 | // eslint-disable-next-line 7 | .filter((call) => call[0] instanceof (command as any)) 8 | .length 9 | 10 | return { 11 | pass: calls === times, 12 | message: () => `Function was called ${calls} times with input, expected ${times} calls` 13 | } 14 | } 15 | 16 | export const expectExtension = { 17 | toHaveReceivedCommandTimes 18 | } 19 | 20 | interface CustomMatchers { 21 | // eslint-disable-next-line 22 | toHaveReceivedCommandTimes: (command: unknown, times: number) => R 23 | } 24 | 25 | declare module 'vitest' { 26 | // eslint-disable-next-line 27 | interface Assertion extends CustomMatchers {} 28 | // eslint-disable-next-line 29 | interface AsymmetricMatchersContaining extends CustomMatchers {} 30 | } 31 | -------------------------------------------------------------------------------- /src/services/oauth/apple.service.ts: -------------------------------------------------------------------------------- 1 | import httpStatus from 'http-status' 2 | import { ApiError } from '../../utils/api-error' 3 | 4 | type AppleResponse = { 5 | error?: string 6 | id_token?: string 7 | } 8 | 9 | export const getIdTokenFromCode = async ( 10 | code: string, 11 | clientId: string, 12 | clientSecret: string, 13 | redirectUrl: string 14 | ) => { 15 | const params = { 16 | grant_type: 'authorization_code', 17 | code, 18 | client_id: clientId, 19 | client_secret: clientSecret, 20 | redirect_uri: redirectUrl, 21 | response_mode: 'form_post' 22 | } 23 | const response = await fetch('https://appleid.apple.com/auth/token', { 24 | method: 'POST', 25 | headers: { 'content-type': 'application/x-www-form-urlencoded' }, 26 | body: new URLSearchParams(params).toString() 27 | }) 28 | const result = (await response.json()) as AppleResponse 29 | if (result.error || !result.id_token) { 30 | throw new ApiError(httpStatus.UNAUTHORIZED, 'Unauthorized') 31 | } 32 | return result.id_token 33 | } 34 | -------------------------------------------------------------------------------- /src/models/user.model.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcryptjs' 2 | import { Selectable } from 'kysely' 3 | import { Role } from '../config/roles' 4 | import { UserTable } from '../tables/user.table' 5 | import { BaseModel } from './base.model' 6 | 7 | export class User extends BaseModel implements Selectable { 8 | id: string 9 | name: string | null 10 | email: string 11 | is_email_verified: boolean 12 | role: Role 13 | password: string | null 14 | 15 | private_fields = ['password', 'created_at', 'updated_at'] 16 | 17 | constructor(user: Selectable) { 18 | super() 19 | this.role = user.role 20 | this.id = user.id 21 | this.name = user.name || null 22 | this.email = user.email 23 | this.is_email_verified = user.is_email_verified 24 | this.role = user.role 25 | this.password = user.password 26 | } 27 | 28 | isPasswordMatch = async (userPassword: string): Promise => { 29 | if (!this.password) throw 'No password connected to user' 30 | return await bcrypt.compare(userPassword, this.password) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/services/oauth/spotify.service.ts: -------------------------------------------------------------------------------- 1 | import httpStatus from 'http-status' 2 | import * as queryString from 'query-string' 3 | import { ApiError } from '../../utils/api-error' 4 | 5 | type Options = { 6 | clientId: string 7 | redirectUrl?: string 8 | scope?: string 9 | responseType?: string 10 | showDialog?: boolean 11 | state?: string 12 | } 13 | 14 | // TODO: remove when worker-auth-providers library fixed 15 | export const redirect = async (options: Options) => { 16 | const { 17 | clientId, 18 | redirectUrl, 19 | scope = 'user-library-read playlist-modify-private', 20 | responseType = 'code', 21 | showDialog = false, 22 | state 23 | } = options 24 | if (!clientId) { 25 | throw new ApiError(httpStatus.BAD_REQUEST, 'Bad request') 26 | } 27 | const params = queryString.stringify({ 28 | client_id: clientId, 29 | redirect_uri: redirectUrl, 30 | response_type: responseType, 31 | scope, 32 | show_dialog: showDialog, 33 | state 34 | }) 35 | const url = `https://accounts.spotify.com/authorize?${params}` 36 | return url 37 | } 38 | -------------------------------------------------------------------------------- /src/services/oauth/facebook.service.ts: -------------------------------------------------------------------------------- 1 | import httpStatus from 'http-status' 2 | import * as queryString from 'query-string' 3 | import { ApiError } from '../../utils/api-error' 4 | 5 | type Options = { 6 | clientId: string 7 | redirectUrl: string 8 | scope?: string 9 | responseType?: string 10 | authType?: string 11 | display?: string 12 | state?: string 13 | } 14 | 15 | // TODO: remove when worker-auth-providers library fixed 16 | export const redirect = async (options: Options) => { 17 | const { 18 | clientId, 19 | redirectUrl, 20 | scope = 'email, user_friends', 21 | responseType = 'code', 22 | authType = 'rerequest', 23 | display = 'popup', 24 | state 25 | } = options 26 | if (!clientId) { 27 | throw new ApiError(httpStatus.BAD_REQUEST, 'Bad request') 28 | } 29 | const params = queryString.stringify({ 30 | client_id: clientId, 31 | redirect_uri: redirectUrl, 32 | scope, 33 | response_type: responseType, 34 | auth_type: authType, 35 | display, 36 | state 37 | }) 38 | const url = `https://www.facebook.com/v4.0/dialog/oauth?${params}` 39 | return url 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Ben Louis Armstrong 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/models/one-time-oauth-code.ts: -------------------------------------------------------------------------------- 1 | import { Selectable } from 'kysely' 2 | import { OneTimeOauthCodeTable } from '../tables/one-time-oauth-code.table' 3 | import { BaseModel } from './base.model' 4 | 5 | export class OneTimeOauthCode extends BaseModel implements Selectable { 6 | code: string 7 | user_id: string 8 | access_token: string 9 | access_token_expires_at: Date 10 | refresh_token: string 11 | refresh_token_expires_at: Date 12 | expires_at: Date 13 | created_at: Date 14 | updated_at: Date 15 | 16 | private_fields = ['created_at', 'updated_at'] 17 | 18 | constructor(oneTimeCode: Selectable) { 19 | super() 20 | this.code = oneTimeCode.code 21 | this.user_id = oneTimeCode.user_id 22 | this.access_token = oneTimeCode.access_token 23 | this.access_token_expires_at = oneTimeCode.access_token_expires_at 24 | this.refresh_token = oneTimeCode.refresh_token 25 | this.refresh_token_expires_at = oneTimeCode.refresh_token_expires_at 26 | this.expires_at = oneTimeCode.expires_at 27 | this.created_at = oneTimeCode.created_at 28 | this.updated_at = oneTimeCode.updated_at 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/config/database.ts: -------------------------------------------------------------------------------- 1 | import { Kysely } from 'kysely' 2 | import { PlanetScaleDialect } from 'kysely-planetscale' 3 | import { AuthProviderTable } from '../tables/oauth.table' 4 | import { OneTimeOauthCodeTable } from '../tables/one-time-oauth-code.table' 5 | import { UserTable } from '../tables/user.table' 6 | import { Config } from './config' 7 | 8 | let dbClient: Kysely 9 | 10 | export interface Database { 11 | user: UserTable 12 | authorisations: AuthProviderTable 13 | one_time_oauth_code: OneTimeOauthCodeTable 14 | } 15 | 16 | export const getDBClient = (databaseConfig: Config['database']): Kysely => { 17 | dbClient = 18 | dbClient || 19 | new Kysely({ 20 | dialect: new PlanetScaleDialect({ 21 | username: databaseConfig.username, 22 | password: databaseConfig.password, 23 | host: databaseConfig.host, 24 | fetch: (url, init) => { 25 | // TODO: REMOVE. 26 | // Remove cache header 27 | // https://github.com/cloudflare/workerd/issues/698 28 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 29 | delete (init as any)['cache'] 30 | return fetch(url, init) 31 | } 32 | }) 33 | }) 34 | return dbClient 35 | } 36 | -------------------------------------------------------------------------------- /src/services/oauth/github.service.ts: -------------------------------------------------------------------------------- 1 | import httpStatus from 'http-status' 2 | import * as queryString from 'query-string' 3 | import { ApiError } from '../../utils/api-error' 4 | 5 | const DEFAULT_SCOPE = ['read:user', 'user:email'] 6 | const DEFAULT_ALLOW_SIGNUP = true 7 | 8 | type Options = { 9 | clientId: string 10 | redirectTo?: string 11 | scope?: string[] 12 | allowSignup?: boolean 13 | state?: string 14 | } 15 | 16 | type Params = { 17 | client_id: string 18 | redirect_uri?: string 19 | scope: string 20 | allow_signup: boolean 21 | state?: string 22 | } 23 | 24 | // TODO: remove when worker-auth-providers library fixed 25 | export const redirect = async (options: Options) => { 26 | const { 27 | clientId, 28 | redirectTo, 29 | scope = DEFAULT_SCOPE, 30 | allowSignup = DEFAULT_ALLOW_SIGNUP, 31 | state 32 | } = options 33 | if (!clientId) { 34 | throw new ApiError(httpStatus.BAD_REQUEST, 'Bad request') 35 | } 36 | const params: Params = { 37 | client_id: clientId, 38 | scope: scope.join(' '), 39 | allow_signup: allowSignup, 40 | state 41 | } 42 | if (redirectTo) { 43 | params.redirect_uri = redirectTo 44 | } 45 | const paramString = queryString.stringify(params) 46 | const githubLoginUrl = `https://github.com/login/oauth/authorize?${paramString}` 47 | return githubLoginUrl 48 | } 49 | -------------------------------------------------------------------------------- /tests/mocks/awsClientStub/mock-client.ts: -------------------------------------------------------------------------------- 1 | import { Client, MetadataBearer } from '@smithy/types' 2 | import { vi } from 'vitest' 3 | import { AwsClientStub, AwsStub } from './aws-client-stub' 4 | 5 | /** 6 | * Creates and attaches a stub of the `Client#send()` method. Only this single method is mocked. 7 | * If method is already a stub, it's replaced. 8 | * @param client `Client` type or instance to replace the method 9 | * @param sandbox Optional sinon sandbox to use 10 | * @return Stub allowing to configure Client's behavior 11 | */ 12 | export const mockClient = ( 13 | client: InstanceOrClassType> 14 | ): AwsClientStub> => { 15 | const instance = isClientInstance(client) ? client : client.prototype 16 | 17 | // const send = instance.send; 18 | // if (vi.isMockFunction(send)) { 19 | // send.restore(); 20 | // } 21 | 22 | const sendStub = vi.spyOn(instance, 'send') 23 | 24 | return new AwsStub(instance, sendStub) 25 | } 26 | 27 | type ClassType = { 28 | prototype: T 29 | } 30 | 31 | type InstanceOrClassType = T | ClassType 32 | 33 | /** 34 | * Type guard to differentiate `Client` instance from a type. 35 | */ 36 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 37 | const isClientInstance = >( 38 | obj: InstanceOrClassType 39 | ): obj is TClient => (obj as TClient).send !== undefined 40 | -------------------------------------------------------------------------------- /src/validations/user.validation.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { password } from './custom.refine.validation' 3 | import { hashPassword } from './custom.transform.validation' 4 | import { roleZodType } from './custom.type.validation' 5 | 6 | export const createUser = z.strictObject({ 7 | email: z.string().email(), 8 | password: z.string().superRefine(password).transform(hashPassword), 9 | name: z.string(), 10 | is_email_verified: z 11 | .any() 12 | .optional() 13 | .transform(() => false), 14 | role: roleZodType 15 | }) 16 | 17 | export type CreateUser = z.infer 18 | 19 | export const getUsers = z.object({ 20 | email: z.string().optional(), 21 | sort_by: z.string().optional().default('id:asc'), 22 | limit: z.coerce.number().optional().default(10), 23 | page: z.coerce.number().optional().default(0) 24 | }) 25 | 26 | export const getUser = z.object({ userId: z.string() }) 27 | 28 | export const updateUser = z.strictObject({ 29 | params: z.object({ userId: z.string() }), 30 | body: z 31 | .object({ 32 | email: z.string().email().optional(), 33 | name: z.string().optional(), 34 | role: z.union([z.literal('admin'), z.literal('user')]).optional() 35 | }) 36 | .refine(({ email, name, role }) => email || name || role, { 37 | message: 'At least one field is required' 38 | }) 39 | }) 40 | 41 | export type UpdateUser = 42 | | z.infer['body'] 43 | | { password: string } 44 | | { is_email_verified: boolean } 45 | 46 | export const deleteUser = z.strictObject({ userId: z.string() }) 47 | -------------------------------------------------------------------------------- /.env.test.example: -------------------------------------------------------------------------------- 1 | # Please note that aws credentials and oauth credentials don't have to work only planetscale 2 | # The apple oauth private key must be in the pkcs8 format 3 | # credentials are required to run the tests. 4 | ENV = 'test' 5 | JWT_SECRET='iamasecret' 6 | JWT_ACCESS_EXPIRATION_MINUTES=30 7 | JWT_REFRESH_EXPIRATION_DAYS=30 8 | JWT_RESET_PASSWORD_EXPIRATION_MINUTES=15 9 | JWT_VERIFY_EMAIL_EXPIRATION_MINUTES=15 10 | DATABASE_NAME='name' 11 | DATABASE_USERNAME='username' 12 | DATABASE_HOST='host' 13 | DATABASE_PASSWORD='password' 14 | AWS_ACCESS_KEY_ID='test' 15 | AWS_SECRET_ACCESS_KEY='test' 16 | AWS_REGION='eu-west-1' 17 | EMAIL_SENDER='noreply@dictionaryapi.io' 18 | SENTRY_DSN='' 19 | OAUTH_WEB_REDIRECT_URL='https://frontend.com/login' 20 | OAUTH_IOS_REDIRECT_URL='app://login' 21 | OAUTH_ANDROID_REDIRECT_URL='app://login' 22 | OAUTH_GITHUB_CLIENT_ID='myclientid' 23 | OAUTH_GITHUB_CLIENT_SECRET='myclientsecret' 24 | OAUTH_DISCORD_CLIENT_ID='myclientid' 25 | OAUTH_DISCORD_CLIENT_SECRET='myclientsecret' 26 | OAUTH_SPOTIFY_CLIENT_ID='myclientid' 27 | OAUTH_SPOTIFY_CLIENT_SECRET='myclientsecret' 28 | OAUTH_GOOGLE_CLIENT_ID='myclientid' 29 | OAUTH_GOOGLE_CLIENT_SECRET='myclientsecret' 30 | OAUTH_GOOGLE_REDIRECT_URL='https://frontend.com/login' 31 | OAUTH_FACEBOOK_CLIENT_ID='myclientid' 32 | OAUTH_FACEBOOK_CLIENT_SECRET='myclientsecret' 33 | OAUTH_APPLE_CLIENT_ID='myclientid' 34 | OAUTH_APPLE_PRIVATE_KEY='-----BEGIN PRIVATE KEY-----\n\n-----END PRIVATE KEY-----' 35 | OAUTH_APPLE_KEY_ID='mykeyid' 36 | OAUTH_APPLE_TEAM_ID='myteamid' 37 | OAUTH_APPLE_JWT_ACCESS_EXPIRATION_MINUTES=30 38 | OAUTH_APPLE_REDIRECT_URL='https://frontend.com/login' 39 | -------------------------------------------------------------------------------- /bindings.d.ts: -------------------------------------------------------------------------------- 1 | import type { JwtPayload } from '@tsndr/cloudflare-worker-jwt' 2 | import type { Toucan } from 'toucan-js' 3 | import type { RateLimiter } from './src/durable-objects/rate-limiter.do' 4 | 5 | type Environment = { 6 | Bindings: { 7 | ENV: string 8 | JWT_SECRET: string 9 | JWT_ACCESS_EXPIRATION_MINUTES: number 10 | JWT_REFRESH_EXPIRATION_DAYS: number 11 | JWT_RESET_PASSWORD_EXPIRATION_MINUTES: number 12 | JWT_VERIFY_EMAIL_EXPIRATION_MINUTES: number 13 | DATABASE_NAME: string 14 | DATABASE_USERNAME: string 15 | DATABASE_PASSWORD: string 16 | DATABASE_HOST: string 17 | RATE_LIMITER: DurableObjectNamespace 18 | SENTRY_DSN: string 19 | AWS_ACCESS_KEY_ID: string 20 | AWS_SECRET_ACCESS_KEY: string 21 | AWS_REGION: string 22 | EMAIL_SENDER: string 23 | OAUTH_WEB_REDIRECT_URL: string 24 | OAUTH_ANDROID_REDIRECT_URL: string 25 | OAUTH_IOS_REDIRECT_URL: string 26 | OAUTH_GITHUB_CLIENT_ID: string 27 | OAUTH_GITHUB_CLIENT_SECRET: string 28 | OAUTH_GOOGLE_CLIENT_ID: string 29 | OAUTH_GOOGLE_CLIENT_SECRET: string 30 | OAUTH_DISCORD_CLIENT_ID: string 31 | OAUTH_DISCORD_CLIENT_SECRET: string 32 | OAUTH_SPOTIFY_CLIENT_ID: string 33 | OAUTH_SPOTIFY_CLIENT_SECRET: string 34 | OAUTH_FACEBOOK_CLIENT_ID: string 35 | OAUTH_FACEBOOK_CLIENT_SECRET: string 36 | OAUTH_APPLE_CLIENT_ID: string 37 | OAUTH_APPLE_KEY_ID: string 38 | OAUTH_APPLE_TEAM_ID: string 39 | OAUTH_APPLE_JWT_ACCESS_EXPIRATION_MINUTES: number 40 | OAUTH_APPLE_PRIVATE_KEY: string 41 | } 42 | Variables: { 43 | payload: JwtPayload 44 | sentry: Toucan 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/validations/auth.validation.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { password } from './custom.refine.validation' 3 | import { hashPassword } from './custom.transform.validation' 4 | 5 | export const register = z.strictObject({ 6 | email: z.string().email(), 7 | password: z.string().superRefine(password).transform(hashPassword), 8 | name: z.string() 9 | }) 10 | 11 | export type Register = z.infer 12 | 13 | export const login = z.strictObject({ 14 | email: z.string(), 15 | password: z.string() 16 | }) 17 | 18 | export const refreshTokens = z.strictObject({ 19 | refresh_token: z.string() 20 | }) 21 | 22 | export const forgotPassword = z.strictObject({ 23 | email: z.string().email() 24 | }) 25 | 26 | export const resetPassword = z.strictObject({ 27 | query: z.object({ 28 | token: z.string() 29 | }), 30 | body: z.object({ 31 | password: z.string().superRefine(password).transform(hashPassword) 32 | }) 33 | }) 34 | 35 | export const verifyEmail = z.strictObject({ 36 | token: z.string() 37 | }) 38 | 39 | export const changePassword = z.strictObject({ 40 | oldPassword: z.string().superRefine(password).transform(hashPassword), 41 | newPassword: z.string().superRefine(password).transform(hashPassword) 42 | }) 43 | 44 | export const oauthCallback = z.object({ 45 | code: z.string(), 46 | platform: z.union([z.literal('web'), z.literal('ios'), z.literal('android')]) 47 | }) 48 | 49 | export const linkApple = z.object({ 50 | code: z.string() 51 | }) 52 | 53 | export const oauthRedirect = z.object({ 54 | state: z.string() 55 | }) 56 | 57 | export const validateOneTimeCode = z.object({ 58 | code: z.string() 59 | }) 60 | 61 | export const stateValidation = z.object({ 62 | platform: z.union([z.literal('web'), z.literal('ios'), z.literal('android')]) 63 | }) 64 | -------------------------------------------------------------------------------- /tests/fixtures/user.fixture.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker' 2 | import bcrypt from 'bcryptjs' 3 | import { Insertable } from 'kysely' 4 | import { Config } from '../../src/config/config' 5 | import { getDBClient } from '../../src/config/database' 6 | import { UserTable } from '../../src/tables/user.table' 7 | import { generateId } from '../../src/utils/utils' 8 | 9 | const password = 'password1' 10 | const salt = bcrypt.genSaltSync(8) 11 | const hashedPassword = bcrypt.hashSync(password, salt) 12 | 13 | export type MockUser = Insertable 14 | 15 | export interface UserResponse { 16 | id: string 17 | name: string 18 | email: string 19 | role: string 20 | is_email_verified: boolean 21 | } 22 | 23 | export const userOne: MockUser = { 24 | id: generateId(), 25 | name: faker.person.fullName(), 26 | email: faker.internet.email().toLowerCase(), 27 | password, 28 | role: 'user', 29 | is_email_verified: false 30 | } 31 | 32 | export const userTwo: MockUser = { 33 | id: generateId(), 34 | name: faker.person.fullName(), 35 | email: faker.internet.email().toLowerCase(), 36 | password, 37 | role: 'user', 38 | is_email_verified: false 39 | } 40 | 41 | export const admin: MockUser = { 42 | id: generateId(), 43 | name: faker.person.fullName(), 44 | email: faker.internet.email().toLowerCase(), 45 | password, 46 | role: 'admin', 47 | is_email_verified: false 48 | } 49 | 50 | export const insertUsers = async (users: MockUser[], databaseConfig: Config['database']) => { 51 | const hashedUsers = users.map((user) => ({ 52 | ...user, 53 | password: user.password ? hashedPassword : null 54 | })) 55 | const client = getDBClient(databaseConfig) 56 | for await (const user of hashedUsers) { 57 | await client.insertInto('user').values(user).executeTakeFirst() 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/types/oauth.types.ts: -------------------------------------------------------------------------------- 1 | import { authProviders } from '../config/authProviders' 2 | import { AppleUser } from '../models/oauth/apple-user.model' 3 | import { DiscordUser } from '../models/oauth/discord-user.model' 4 | import { FacebookUser } from '../models/oauth/facebook-user.model' 5 | import { GithubUser } from '../models/oauth/github-user.model' 6 | import { GoogleUser } from '../models/oauth/google-user.model' 7 | import { SpotifyUser } from '../models/oauth/spotify-user.model' 8 | 9 | export type AuthProviderType = (typeof authProviders)[keyof typeof authProviders] 10 | 11 | export interface OAuthUserType { 12 | _id: string 13 | _email: string 14 | _name?: string 15 | providerType: AuthProviderType 16 | } 17 | 18 | export interface AppleUserType { 19 | sub: string 20 | email?: string 21 | name?: string 22 | } 23 | 24 | export interface DiscordUserType { 25 | id: string 26 | email: string 27 | username: string 28 | } 29 | 30 | export interface FacebookUserType { 31 | id: string 32 | email: string 33 | first_name: string 34 | last_name: string 35 | } 36 | 37 | export interface GithubUserType { 38 | id: number 39 | email: string 40 | name: string 41 | } 42 | 43 | export interface GoogleUserType { 44 | id: string 45 | email: string 46 | name: string 47 | } 48 | 49 | export interface SpotifyUserType { 50 | id: string 51 | email: string 52 | display_name: string 53 | } 54 | 55 | export interface OauthUserTypes { 56 | facebook: FacebookUserType 57 | discord: DiscordUserType 58 | google: GoogleUserType 59 | spotify: SpotifyUserType 60 | apple: AppleUserType 61 | github: GithubUserType 62 | } 63 | 64 | export type ProviderUserMapping = { 65 | [key in AuthProviderType]: new ( 66 | user: OauthUserTypes[key] 67 | ) => FacebookUser | DiscordUser | GoogleUser | SpotifyUser | AppleUser | GithubUser 68 | } 69 | -------------------------------------------------------------------------------- /tests/fixtures/authorisations.fixture.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker' 2 | import { Insertable } from 'kysely' 3 | import { authProviders } from '../../src/config/authProviders' 4 | import { Config } from '../../src/config/config' 5 | import { getDBClient } from '../../src/config/database' 6 | import { AuthProviderTable } from '../../src/tables/oauth.table' 7 | 8 | export const githubAuthorisation = (userId: string) => ({ 9 | provider_type: authProviders.GITHUB, 10 | provider_user_id: faker.number.int().toString(), 11 | user_id: userId 12 | }) 13 | 14 | export const discordAuthorisation = (userId: string) => ({ 15 | provider_type: authProviders.DISCORD, 16 | provider_user_id: faker.number.int().toString(), 17 | user_id: userId 18 | }) 19 | 20 | export const spotifyAuthorisation = (userId: string) => ({ 21 | provider_type: authProviders.SPOTIFY, 22 | provider_user_id: faker.number.int().toString(), 23 | user_id: userId 24 | }) 25 | 26 | export const googleAuthorisation = (userId: string) => ({ 27 | provider_type: authProviders.GOOGLE, 28 | provider_user_id: faker.number.int().toString(), 29 | user_id: userId 30 | }) 31 | 32 | export const facebookAuthorisation = (userId: string) => ({ 33 | provider_type: authProviders.FACEBOOK, 34 | provider_user_id: faker.number.int().toString(), 35 | user_id: userId 36 | }) 37 | 38 | export const appleAuthorisation = (userId: string) => ({ 39 | provider_type: authProviders.APPLE, 40 | provider_user_id: faker.number.int().toString(), 41 | user_id: userId 42 | }) 43 | 44 | export const insertAuthorisations = async ( 45 | authorisations: Insertable[], 46 | databaseConfig: Config['database'] 47 | ) => { 48 | const client = getDBClient(databaseConfig) 49 | for await (const authorisation of authorisations) { 50 | await client.insertInto('authorisations').values(authorisation).executeTakeFirst() 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /.dev.vars.example: -------------------------------------------------------------------------------- 1 | # This file is used to set secrets for running a local dev server via npm run dev as well as tests 2 | # Use a valid fake PKCS8 key for apple oauth 3 | JWT_SECRET = "iamarandomjwtsecret" 4 | DATABASE_PASSWORD = "database password" 5 | AWS_ACCESS_KEY_ID = "realorfake" 6 | AWS_SECRET_ACCESS_KEY = "realorfake" 7 | SENTRY_DSN = "realorempty" 8 | OAUTH_GITHUB_CLIENT_SECRET = "realorfake" 9 | OAUTH_DISCORD_CLIENT_SECRET = "realorfake" 10 | OAUTH_SPOTIFY_CLIENT_SECRET = "realorfake" 11 | OAUTH_GOOGLE_CLIENT_SECRET = "realorfake" 12 | OAUTH_FACEBOOK_CLIENT_SECRET = "realorfake" 13 | 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-----" 14 | -------------------------------------------------------------------------------- /src/services/email.service.ts: -------------------------------------------------------------------------------- 1 | import { SESClient, SendEmailCommand, Message } from '@aws-sdk/client-ses' 2 | import { Config } from '../config/config' 3 | 4 | let client: SESClient 5 | 6 | export interface EmailData { 7 | name: string 8 | token: string 9 | } 10 | 11 | const getClient = (awsConfig: Config['aws']): SESClient => { 12 | client = 13 | client || 14 | new SESClient({ 15 | credentials: { 16 | accessKeyId: awsConfig.accessKeyId, 17 | secretAccessKey: awsConfig.secretAccessKey 18 | }, 19 | region: awsConfig.region 20 | }) 21 | return client 22 | } 23 | 24 | const sendEmail = async ( 25 | to: string, 26 | sender: string, 27 | message: Message, 28 | awsConfig: Config['aws'] 29 | ): Promise => { 30 | const sesClient = getClient(awsConfig) 31 | const command = new SendEmailCommand({ 32 | Destination: { ToAddresses: [to] }, 33 | Source: sender, 34 | Message: message 35 | }) 36 | await sesClient.send(command) 37 | } 38 | 39 | export const sendResetPasswordEmail = async ( 40 | email: string, 41 | emailData: EmailData, 42 | config: Config 43 | ): Promise => { 44 | const message = { 45 | Subject: { 46 | Data: 'Reset your password', 47 | Charset: 'UTF-8' 48 | }, 49 | Body: { 50 | Text: { 51 | Charset: 'UTF-8', 52 | Data: ` 53 | Hello ${emailData.name} 54 | Please reset your password by clicking the following link: 55 | ${emailData.token} 56 | ` 57 | } 58 | } 59 | } 60 | await sendEmail(email, config.email.sender, message, config.aws) 61 | } 62 | 63 | export const sendVerificationEmail = async ( 64 | email: string, 65 | emailData: EmailData, 66 | config: Config 67 | ): Promise => { 68 | const message = { 69 | Subject: { 70 | Data: 'Verify your email address', 71 | Charset: 'UTF-8' 72 | }, 73 | Body: { 74 | Text: { 75 | Charset: 'UTF-8', 76 | Data: ` 77 | Hello ${emailData.name} 78 | Please verify your email by clicking the following link: 79 | ${emailData.token} 80 | ` 81 | } 82 | } 83 | } 84 | await sendEmail(email, config.email.sender, message, config.aws) 85 | } 86 | -------------------------------------------------------------------------------- /src/controllers/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { Handler } from 'hono' 2 | import httpStatus from 'http-status' 3 | import { Environment } from '../../bindings' 4 | import { getConfig } from '../config/config' 5 | import * as userService from '../services/user.service' 6 | import { ApiError } from '../utils/api-error' 7 | import * as userValidation from '../validations/user.validation' 8 | 9 | export const createUser: Handler = async (c) => { 10 | const config = getConfig(c.env) 11 | const bodyParse = await c.req.json() 12 | const body = await userValidation.createUser.parseAsync(bodyParse) 13 | const user = await userService.createUser(body, config.database) 14 | return c.json(user, httpStatus.CREATED) 15 | } 16 | 17 | export const getUsers: Handler = async (c) => { 18 | const config = getConfig(c.env) 19 | const queryParse = c.req.query() 20 | const query = userValidation.getUsers.parse(queryParse) 21 | const filter = { email: query.email } 22 | const options = { sortBy: query.sort_by, limit: query.limit, page: query.page } 23 | const result = await userService.queryUsers(filter, options, config.database) 24 | return c.json(result, httpStatus.OK) 25 | } 26 | 27 | export const getUser: Handler = async (c) => { 28 | const config = getConfig(c.env) 29 | const paramsParse = c.req.param() 30 | const params = userValidation.getUser.parse(paramsParse) 31 | const user = await userService.getUserById(params.userId, config.database) 32 | if (!user) { 33 | throw new ApiError(httpStatus.NOT_FOUND, 'User not found') 34 | } 35 | return c.json(user, httpStatus.OK) 36 | } 37 | 38 | export const updateUser: Handler = async (c) => { 39 | const config = getConfig(c.env) 40 | const paramsParse = c.req.param() 41 | const bodyParse = await c.req.json() 42 | const { params, body } = userValidation.updateUser.parse({ params: paramsParse, body: bodyParse }) 43 | const user = await userService.updateUserById(params.userId, body, config.database) 44 | return c.json(user, httpStatus.OK) 45 | } 46 | 47 | export const deleteUser: Handler = async (c) => { 48 | const config = getConfig(c.env) 49 | const paramsParse = c.req.param() 50 | const params = userValidation.deleteUser.parse(paramsParse) 51 | await userService.deleteUserById(params.userId, config.database) 52 | c.status(httpStatus.NO_CONTENT) 53 | return c.body(null) 54 | } 55 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js' 2 | import importx from 'eslint-plugin-import-x' 3 | import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended' 4 | import vitest from 'eslint-plugin-vitest' 5 | import globals from 'globals' 6 | import tseslint from 'typescript-eslint' 7 | 8 | const defaultFiles = [ 9 | 'src/**', 10 | 'tests/**', 11 | 'bindings.d.ts', 12 | 'scripts/**', 13 | 'migrations/**' 14 | ] 15 | 16 | const config = { 17 | languageOptions: { 18 | sourceType: 'module', 19 | ecmaVersion: 2021, 20 | globals: { 21 | ...globals.node, 22 | ...globals.browser, 23 | ...globals.serviceworker, 24 | fetch: 'readonly', 25 | Response: 'readonly', 26 | Request: 'readonly', 27 | addEventListener: 'readonly', 28 | ENV: 'readonly' 29 | }, 30 | }, 31 | plugins: { 'import-x': importx }, 32 | rules: { 33 | quotes: ['error', 'single'], 34 | 'no-console': 'error', 35 | 'sort-imports': 'off', 36 | 'import-x/order': [ 37 | 'error', 38 | { 39 | alphabetize: { order: 'asc' }, 40 | } 41 | ], 42 | 'node/no-missing-import': 'off', 43 | 'node/no-missing-require': 'off', 44 | 'node/no-deprecated-api': 'off', 45 | 'node/no-unpublished-import': 'off', 46 | 'node/no-unpublished-require': 'off', 47 | 'node/no-unsupported-features/es-syntax': 'off', 48 | semi: ['error', 'never'], 49 | 'no-debugger': ['error'], 50 | 'no-empty': ['warn', { allowEmptyCatch: true }], 51 | 'no-process-exit': 'off', 52 | 'no-useless-escape': 'off', 53 | 'max-len': ['error', { code: 100 }], 54 | '@typescript-eslint/no-unused-vars': [ 55 | 'error', 56 | { 57 | argsIgnorePattern: '^_', 58 | varsIgnorePattern: '^_', 59 | caughtErrorsIgnorePattern: '^_', 60 | }, 61 | ], 62 | }, 63 | files: defaultFiles 64 | } 65 | 66 | export const testConfig = { 67 | ...vitest.configs.recommended, 68 | plugins: { vitest: vitest }, 69 | files: ['tests/**'] 70 | } 71 | 72 | export default tseslint.config( 73 | { 74 | ignores: ['dist/', 'coverage/', 'node_modules/'], 75 | }, 76 | { 77 | files: defaultFiles, 78 | ...eslint.configs.recommended 79 | }, 80 | ...tseslint.configs.recommended, 81 | config, 82 | testConfig, 83 | { 84 | files: defaultFiles, 85 | ...eslintPluginPrettierRecommended 86 | } 87 | ) 88 | 89 | -------------------------------------------------------------------------------- /src/middlewares/error.ts: -------------------------------------------------------------------------------- 1 | import { getSentry } from '@hono/sentry' 2 | import type { ErrorHandler } from 'hono' 3 | import { StatusCode } from 'hono/utils/http-status' 4 | import httpStatus from 'http-status' 5 | import type { Toucan } from 'toucan-js' 6 | import { ZodError } from 'zod' 7 | import { Environment } from '../../bindings' 8 | import { ApiError } from '../utils/api-error' 9 | import { generateZodErrorMessage } from '../utils/zod' 10 | 11 | const genericJSONErrMsg = 'Unexpected end of JSON input' 12 | 13 | export const errorConverter = (err: unknown, sentry: Toucan): ApiError => { 14 | let error = err 15 | if (error instanceof ZodError) { 16 | const errorMessage = generateZodErrorMessage(error) 17 | error = new ApiError(httpStatus.BAD_REQUEST, errorMessage) 18 | } else if (error instanceof SyntaxError && error.message.includes(genericJSONErrMsg)) { 19 | throw new ApiError(httpStatus.BAD_REQUEST, 'Invalid JSON payload') 20 | } else if (!(error instanceof ApiError)) { 21 | const castedErr = (typeof error === 'object' ? error : {}) as Record 22 | const statusCode: StatusCode = 23 | typeof castedErr.statusCode === 'number' 24 | ? (castedErr.statusCode as StatusCode) 25 | : httpStatus.INTERNAL_SERVER_ERROR 26 | const message = (castedErr.description || 27 | castedErr.message || 28 | httpStatus[statusCode.toString() as keyof typeof httpStatus]) as string 29 | if (statusCode >= httpStatus.INTERNAL_SERVER_ERROR) { 30 | // Log any unhandled application error 31 | sentry.captureException(error) 32 | } 33 | error = new ApiError(statusCode, message, false) 34 | } 35 | return error as ApiError 36 | } 37 | 38 | export const errorHandler: ErrorHandler = async (err, c) => { 39 | // Can't load config in case error is inside config so load env here and default 40 | // to highest obscurity aka production if env is not set 41 | const env = c.env.ENV || 'production' 42 | const sentry = getSentry(c) 43 | const error = errorConverter(err, sentry) 44 | if (env === 'production' && !error.isOperational) { 45 | error.statusCode = httpStatus.INTERNAL_SERVER_ERROR 46 | error.message = httpStatus[httpStatus.INTERNAL_SERVER_ERROR].toString() 47 | } 48 | const response = { 49 | code: error.statusCode, 50 | message: error.message, 51 | ...(env === 'development' && { stack: err.stack }) 52 | } 53 | delete c.error // Don't pass to sentry middleware as it is either logged or already handled 54 | return c.json(response, error.statusCode as StatusCode) 55 | } 56 | -------------------------------------------------------------------------------- /src/middlewares/auth.ts: -------------------------------------------------------------------------------- 1 | import jwt, { JwtPayload } from '@tsndr/cloudflare-worker-jwt' 2 | import { MiddlewareHandler } from 'hono' 3 | import httpStatus from 'http-status' 4 | import { Environment } from '../../bindings' 5 | import { getConfig } from '../config/config' 6 | import { roleRights, Permission, Role } from '../config/roles' 7 | import { tokenTypes } from '../config/tokens' 8 | import { getUserById } from '../services/user.service' 9 | import { ApiError } from '../utils/api-error' 10 | 11 | const authenticate = async (jwtToken: string, secret: string) => { 12 | let authorized = false 13 | let payload 14 | try { 15 | authorized = await jwt.verify(jwtToken, secret) 16 | const decoded = jwt.decode(jwtToken) 17 | payload = decoded.payload as JwtPayload 18 | authorized = authorized && payload.type === tokenTypes.ACCESS 19 | } catch {} 20 | return { authorized, payload } 21 | } 22 | 23 | export const auth = 24 | (...requiredRights: Permission[]): MiddlewareHandler => 25 | async (c, next) => { 26 | const credentials = c.req.raw.headers.get('Authorization') 27 | const config = getConfig(c.env) 28 | if (!credentials) { 29 | throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate') 30 | } 31 | 32 | const parts = credentials.split(/\s+/) 33 | if (parts.length !== 2) { 34 | throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate') 35 | } 36 | 37 | const jwtToken = parts[1] 38 | const { authorized, payload } = await authenticate(jwtToken, config.jwt.secret) 39 | 40 | if (!authorized || !payload || !payload.sub) { 41 | throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate') 42 | } 43 | 44 | if (requiredRights.length) { 45 | const userRights = roleRights[payload.role as Role] 46 | const hasRequiredRights = requiredRights.every((requiredRight) => 47 | (userRights as unknown as string[]).includes(requiredRight) 48 | ) 49 | if (!hasRequiredRights && c.req.param('userId') !== payload.sub) { 50 | throw new ApiError(httpStatus.FORBIDDEN, 'Forbidden') 51 | } 52 | } 53 | if (!payload.isEmailVerified) { 54 | const user = await getUserById(payload.sub, config['database']) 55 | if (!user) { 56 | throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate') 57 | } 58 | const url = new URL(c.req.url) 59 | if (url.pathname !== '/v1/auth/send-verification-email') { 60 | throw new ApiError(httpStatus.FORBIDDEN, 'Please verify your email') 61 | } 62 | } 63 | c.set('payload', payload) 64 | await next() 65 | } 66 | -------------------------------------------------------------------------------- /src/controllers/auth/oauth/github.controller.ts: -------------------------------------------------------------------------------- 1 | import { Handler } from 'hono' 2 | import httpStatus from 'http-status' 3 | import { github } from 'worker-auth-providers' 4 | import { Environment } from '../../../../bindings' 5 | import { authProviders } from '../../../config/authProviders' 6 | import { getConfig } from '../../../config/config' 7 | import * as githubService from '../../../services/oauth/github.service' 8 | import * as authValidation from '../../../validations/auth.validation' 9 | import { 10 | oauthCallback, 11 | oauthLink, 12 | deleteOauthLink, 13 | validateCallbackBody, 14 | getRedirectUrl 15 | } from './oauth.controller' 16 | 17 | export const githubRedirect: Handler = async (c) => { 18 | const config = getConfig(c.env) 19 | const { state } = authValidation.oauthRedirect.parse(c.req.query()) 20 | const redirectUrl = getRedirectUrl(state, config) 21 | const location = await githubService.redirect({ 22 | clientId: config.oauth.provider.github.clientId, 23 | redirectTo: redirectUrl, 24 | state: state 25 | }) 26 | return c.redirect(location, httpStatus.FOUND) 27 | } 28 | 29 | export const githubCallback: Handler = async (c) => { 30 | const config = getConfig(c.env) 31 | const bodyParse = await c.req.json() 32 | const { platform, code } = authValidation.oauthCallback.parse(bodyParse) 33 | const request = await validateCallbackBody(c, code) 34 | const redirectUrl = config.oauth.platform[platform].redirectUrl 35 | const oauthRequest = github.users({ 36 | options: { 37 | clientId: config.oauth.provider.github.clientId, 38 | clientSecret: config.oauth.provider.github.clientSecret, 39 | redirectUrl: redirectUrl 40 | }, 41 | request 42 | }) 43 | return oauthCallback(c, oauthRequest, authProviders.GITHUB) 44 | } 45 | 46 | export const linkGithub: Handler = async (c) => { 47 | const config = getConfig(c.env) 48 | const bodyParse = await c.req.json() 49 | const { platform, code } = authValidation.oauthCallback.parse(bodyParse) 50 | const request = await validateCallbackBody(c, code) 51 | const redirectUrl = config.oauth.platform[platform].redirectUrl 52 | const oauthRequest = github.users({ 53 | options: { 54 | clientId: config.oauth.provider.github.clientId, 55 | clientSecret: config.oauth.provider.github.clientSecret, 56 | redirectUrl: redirectUrl 57 | }, 58 | request 59 | }) 60 | return oauthLink(c, oauthRequest, authProviders.GITHUB) 61 | } 62 | 63 | export const deleteGithubLink: Handler = async (c) => { 64 | return deleteOauthLink(c, authProviders.GITHUB) 65 | } 66 | -------------------------------------------------------------------------------- /src/controllers/auth/oauth/facebook.controller.ts: -------------------------------------------------------------------------------- 1 | import { Handler } from 'hono' 2 | import httpStatus from 'http-status' 3 | import { facebook } from 'worker-auth-providers' 4 | import { Environment } from '../../../../bindings' 5 | import { authProviders } from '../../../config/authProviders' 6 | import { getConfig } from '../../../config/config' 7 | import * as facebookService from '../../../services/oauth/facebook.service' 8 | import * as authValidation from '../../../validations/auth.validation' 9 | import { 10 | oauthCallback, 11 | oauthLink, 12 | deleteOauthLink, 13 | validateCallbackBody, 14 | getRedirectUrl 15 | } from './oauth.controller' 16 | 17 | export const facebookRedirect: Handler = async (c) => { 18 | const config = getConfig(c.env) 19 | const { state } = authValidation.oauthRedirect.parse(c.req.query()) 20 | const redirectUrl = getRedirectUrl(state, config) 21 | const location = await facebookService.redirect({ 22 | clientId: config.oauth.provider.facebook.clientId, 23 | redirectUrl: redirectUrl, 24 | state: state 25 | }) 26 | return c.redirect(location, httpStatus.FOUND) 27 | } 28 | 29 | export const facebookCallback: Handler = async (c) => { 30 | const config = getConfig(c.env) 31 | const bodyParse = await c.req.json() 32 | const { platform, code } = authValidation.oauthCallback.parse(bodyParse) 33 | const redirectUrl = config.oauth.platform[platform].redirectUrl 34 | const request = await validateCallbackBody(c, code) 35 | const oauthRequest = facebook.users({ 36 | options: { 37 | clientId: config.oauth.provider.facebook.clientId, 38 | clientSecret: config.oauth.provider.facebook.clientSecret, 39 | redirectUrl: redirectUrl 40 | }, 41 | request 42 | }) 43 | return oauthCallback(c, oauthRequest, authProviders.FACEBOOK) 44 | } 45 | 46 | export const linkFacebook: Handler = async (c) => { 47 | const config = getConfig(c.env) 48 | const bodyParse = await c.req.json() 49 | const { platform, code } = authValidation.oauthCallback.parse(bodyParse) 50 | const request = await validateCallbackBody(c, code) 51 | const redirectUrl = config.oauth.platform[platform].redirectUrl 52 | const oauthRequest = facebook.users({ 53 | options: { 54 | clientId: config.oauth.provider.facebook.clientId, 55 | clientSecret: config.oauth.provider.facebook.clientSecret, 56 | redirectUrl: redirectUrl 57 | }, 58 | request 59 | }) 60 | return oauthLink(c, oauthRequest, authProviders.FACEBOOK) 61 | } 62 | 63 | export const deleteFacebookLink: Handler = async (c) => { 64 | return deleteOauthLink(c, authProviders.FACEBOOK) 65 | } 66 | -------------------------------------------------------------------------------- /bin/createApp.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /* eslint no-console: "off" */ 4 | import { exec as child_exec } from 'child_process' 5 | import fs from 'fs' 6 | import path from 'path' 7 | import util from 'util' 8 | 9 | // Utility functions 10 | const exec = util.promisify(child_exec) 11 | 12 | const runCmd = async (command) => { 13 | try { 14 | const { stdout, stderr } = await exec(command) 15 | console.log(stdout) 16 | console.log(stderr) 17 | } catch(err) { 18 | console.log(err) 19 | } 20 | } 21 | 22 | // Validate arguments 23 | if (process.argv.length < 3) { 24 | console.log('Please specify the target project directory.') 25 | console.log('For example:') 26 | console.log(' npx create-cf-planetscale-app my-app') 27 | console.log(' OR') 28 | console.log(' npm init create-cf-planetscale-app my-app') 29 | process.exit(1) 30 | } 31 | 32 | // Define constants 33 | const ownPath = process.cwd() 34 | const folderName = process.argv[2] 35 | const appPath = path.join(ownPath, folderName) 36 | const repo = 'https://github.com/OultimoCoder/cloudflare-planetscale-hono-boilerplate' 37 | 38 | // Check if directory already exists 39 | try { 40 | fs.mkdirSync(appPath) 41 | } catch (err) { 42 | if (err.code === 'EEXIST') { 43 | console.log('Directory already exists. Please choose another name for the project.') 44 | } else { 45 | console.log(err) 46 | } 47 | process.exit(1) 48 | } 49 | 50 | const setup = async () => { 51 | try { 52 | // Clone repo 53 | console.log(`Downloading files from repo ${repo}`) 54 | await runCmd(`git clone --depth 1 ${repo} ${folderName}`) 55 | console.log('Cloned successfully.') 56 | console.log('') 57 | 58 | // Change directory 59 | process.chdir(appPath) 60 | 61 | // Install dependencies 62 | console.log('Installing dependencies...') 63 | await runCmd('npm install') 64 | console.log('Dependencies installed successfully.') 65 | console.log() 66 | 67 | // Copy wrangler.toml 68 | fs.copyFileSync( 69 | path.join(appPath, 'wrangler.toml.example'), 70 | path.join(appPath, 'wrangler.toml') 71 | ) 72 | console.log('wrangler.toml copied.') 73 | 74 | // Delete .git folder 75 | await runCmd('npx rimraf ./.git') 76 | 77 | // Remove extra files 78 | fs.unlinkSync(path.join(appPath, 'TODO.md')) 79 | fs.unlinkSync(path.join(appPath, 'bin', 'createApp.js')) 80 | fs.rmdirSync(path.join(appPath, 'bin')) 81 | 82 | console.log('Installation is now complete!') 83 | console.log() 84 | console.log('Enjoy your production-ready Cloudflare Workers project!') 85 | console.log('Check README.md for more info.') 86 | } catch (error) { 87 | console.log(error) 88 | } 89 | } 90 | 91 | setup() 92 | -------------------------------------------------------------------------------- /src/middlewares/rate-limiter.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | import { Context, MiddlewareHandler } from 'hono' 3 | import httpStatus from 'http-status' 4 | import { Environment } from '../../bindings' 5 | import { ApiError } from '../utils/api-error' 6 | 7 | const fakeDomain = 'http://rate-limiter.com/' 8 | 9 | const getRateLimitKey = (c: Context) => { 10 | const ip = c.req.raw.headers.get('cf-connecting-ip') 11 | const user = c.get('payload')?.sub 12 | const uniqueKey = user ? user : ip 13 | return uniqueKey 14 | } 15 | 16 | const getCacheKey = (endpoint: string, key: number | string, limit: number, interval: number) => { 17 | return `${fakeDomain}${endpoint}/${key}/${limit}/${interval}` 18 | } 19 | 20 | const setRateLimitHeaders = ( 21 | c: Context, 22 | secondsExpires: number, 23 | limit: number, 24 | remaining: number, 25 | interval: number 26 | ) => { 27 | c.header('X-RateLimit-Limit', limit.toString()) 28 | c.header('X-RateLimit-Remaining', remaining.toString()) 29 | c.header('X-RateLimit-Reset', secondsExpires.toString()) 30 | c.header('X-RateLimit-Policy', `${limit};w=${interval};comment="Sliding window"`) 31 | } 32 | 33 | export const rateLimit = (interval: number, limit: number): MiddlewareHandler => { 34 | return async (c, next) => { 35 | const key = getRateLimitKey(c) 36 | const endpoint = new URL(c.req.url).pathname 37 | const id = c.env.RATE_LIMITER.idFromName(key) 38 | const rateLimiter = c.env.RATE_LIMITER.get(id) 39 | const cache = await caches.open('rate-limiter') 40 | const cacheKey = getCacheKey(endpoint, key, limit, interval) 41 | const cached = await cache.match(cacheKey) 42 | let res: Response 43 | if (!cached) { 44 | res = await rateLimiter.fetch( 45 | new Request(fakeDomain, { 46 | method: 'POST', 47 | body: JSON.stringify({ 48 | scope: endpoint, 49 | key, 50 | limit, 51 | interval 52 | }) 53 | }) 54 | ) 55 | } else { 56 | res = cached 57 | } 58 | const clonedRes = res.clone() 59 | // eslint-disable-next-line no-console 60 | console.log() // This randomly fixes isolated storage errors 61 | const body = await clonedRes.json<{ blocked: boolean; remaining: number; expires: string }>() 62 | const secondsExpires = dayjs(body.expires).unix() - dayjs().unix() 63 | setRateLimitHeaders(c, secondsExpires, limit, body.remaining, interval) 64 | if (body.blocked) { 65 | if (!cached) { 66 | // Only cache blocked responses 67 | c.executionCtx.waitUntil(cache.put(cacheKey, res)) 68 | } 69 | throw new ApiError(httpStatus.TOO_MANY_REQUESTS, 'Too many requests') 70 | } 71 | await next() 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/controllers/auth/oauth/google.controller.ts: -------------------------------------------------------------------------------- 1 | import { Handler } from 'hono' 2 | import httpStatus from 'http-status' 3 | import { google } from 'worker-auth-providers' 4 | import { Environment } from '../../../../bindings' 5 | import { authProviders } from '../../../config/authProviders' 6 | import { getConfig } from '../../../config/config' 7 | import { GoogleUserType } from '../../../types/oauth.types' 8 | import * as authValidation from '../../../validations/auth.validation' 9 | import { 10 | oauthCallback, 11 | oauthLink, 12 | deleteOauthLink, 13 | validateCallbackBody, 14 | getRedirectUrl 15 | } from './oauth.controller' 16 | 17 | export const googleRedirect: Handler = async (c) => { 18 | const config = getConfig(c.env) 19 | const { state } = authValidation.oauthRedirect.parse(c.req.query()) 20 | const redirectUrl = getRedirectUrl(state, config) 21 | const location = await google.redirect({ 22 | options: { 23 | clientId: config.oauth.provider.google.clientId, 24 | redirectUrl: redirectUrl, 25 | state: state 26 | } 27 | }) 28 | return c.redirect(location, httpStatus.FOUND) 29 | } 30 | 31 | export const googleCallback: Handler = async (c) => { 32 | const config = getConfig(c.env) 33 | const bodyParse = await c.req.json() 34 | const { platform, code } = authValidation.oauthCallback.parse(bodyParse) 35 | const request = await validateCallbackBody(c, code) 36 | const redirectUrl = config.oauth.platform[platform].redirectUrl 37 | const oauthRequest = google.users({ 38 | options: { 39 | clientId: config.oauth.provider.google.clientId, 40 | clientSecret: config.oauth.provider.google.clientSecret, 41 | redirectUrl: redirectUrl 42 | }, 43 | request 44 | }) as Promise<{ user: GoogleUserType; tokens: unknown }> 45 | return oauthCallback(c, oauthRequest, authProviders.GOOGLE) 46 | } 47 | 48 | export const linkGoogle: Handler = async (c) => { 49 | const config = getConfig(c.env) 50 | const bodyParse = await c.req.json() 51 | const { platform, code } = authValidation.oauthCallback.parse(bodyParse) 52 | const request = await validateCallbackBody(c, code) 53 | const redirectUrl = config.oauth.platform[platform].redirectUrl 54 | const oauthRequest = google.users({ 55 | options: { 56 | clientId: config.oauth.provider.google.clientId, 57 | clientSecret: config.oauth.provider.google.clientSecret, 58 | redirectUrl: redirectUrl 59 | }, 60 | request 61 | }) as Promise<{ user: GoogleUserType; tokens: unknown }> 62 | return oauthLink(c, oauthRequest, authProviders.GOOGLE) 63 | } 64 | 65 | export const deleteGoogleLink: Handler = async (c) => { 66 | return deleteOauthLink(c, authProviders.GOOGLE) 67 | } 68 | -------------------------------------------------------------------------------- /src/controllers/auth/oauth/discord.controller.ts: -------------------------------------------------------------------------------- 1 | import { Handler } from 'hono' 2 | import httpStatus from 'http-status' 3 | import { discord } from 'worker-auth-providers' 4 | import { Environment } from '../../../../bindings' 5 | import { authProviders } from '../../../config/authProviders' 6 | import { getConfig } from '../../../config/config' 7 | import { DiscordUserType } from '../../../types/oauth.types' 8 | import * as authValidation from '../../../validations/auth.validation' 9 | import { 10 | oauthCallback, 11 | oauthLink, 12 | deleteOauthLink, 13 | validateCallbackBody, 14 | getRedirectUrl 15 | } from './oauth.controller' 16 | 17 | export const discordRedirect: Handler = async (c) => { 18 | const config = getConfig(c.env) 19 | const { state } = authValidation.oauthRedirect.parse(c.req.query()) 20 | const redirectUrl = getRedirectUrl(state, config) 21 | const location = await discord.redirect({ 22 | options: { 23 | clientId: config.oauth.provider.discord.clientId, 24 | redirectUrl: redirectUrl, 25 | state: state, 26 | scope: 'identify email' 27 | } 28 | }) 29 | return c.redirect(location, httpStatus.FOUND) 30 | } 31 | 32 | export const discordCallback: Handler = async (c) => { 33 | const config = getConfig(c.env) 34 | const bodyParse = await c.req.json() 35 | const { platform, code } = authValidation.oauthCallback.parse(bodyParse) 36 | const redirectUrl = config.oauth.platform[platform].redirectUrl 37 | const request = await validateCallbackBody(c, code) 38 | const oauthRequest = discord.users({ 39 | options: { 40 | clientId: config.oauth.provider.discord.clientId, 41 | clientSecret: config.oauth.provider.discord.clientSecret, 42 | redirectUrl: redirectUrl 43 | }, 44 | request 45 | }) as Promise<{ user: DiscordUserType; tokens: unknown }> 46 | return oauthCallback(c, oauthRequest, authProviders.DISCORD) 47 | } 48 | 49 | export const linkDiscord: Handler = async (c) => { 50 | const config = getConfig(c.env) 51 | const bodyParse = await c.req.json() 52 | const { platform, code } = authValidation.oauthCallback.parse(bodyParse) 53 | const redirectUrl = config.oauth.platform[platform].redirectUrl 54 | const request = await validateCallbackBody(c, code) 55 | const oauthRequest = discord.users({ 56 | options: { 57 | clientId: config.oauth.provider.discord.clientId, 58 | clientSecret: config.oauth.provider.discord.clientSecret, 59 | redirectUrl: redirectUrl 60 | }, 61 | request 62 | }) as Promise<{ user: DiscordUserType; tokens: unknown }> 63 | return oauthLink(c, oauthRequest, authProviders.DISCORD) 64 | } 65 | 66 | export const deleteDiscordLink: Handler = async (c) => { 67 | return deleteOauthLink(c, authProviders.DISCORD) 68 | } 69 | -------------------------------------------------------------------------------- /src/controllers/auth/oauth/spotify.controller.ts: -------------------------------------------------------------------------------- 1 | import { Handler } from 'hono' 2 | import httpStatus from 'http-status' 3 | import { spotify } from 'worker-auth-providers' 4 | import { Environment } from '../../../../bindings' 5 | import { authProviders } from '../../../config/authProviders' 6 | import { getConfig } from '../../../config/config' 7 | import * as spotifyService from '../../../services/oauth/spotify.service' 8 | import { SpotifyUserType } from '../../../types/oauth.types' 9 | import * as authValidation from '../../../validations/auth.validation' 10 | import { 11 | oauthCallback, 12 | oauthLink, 13 | deleteOauthLink, 14 | validateCallbackBody, 15 | getRedirectUrl 16 | } from './oauth.controller' 17 | 18 | export const spotifyRedirect: Handler = async (c) => { 19 | const config = getConfig(c.env) 20 | const { state } = authValidation.oauthRedirect.parse(c.req.query()) 21 | const redirectUrl = getRedirectUrl(state, config) 22 | const location = await spotifyService.redirect({ 23 | clientId: config.oauth.provider.spotify.clientId, 24 | redirectUrl: redirectUrl, 25 | state: state, 26 | scope: 'user-read-email' 27 | }) 28 | return c.redirect(location, httpStatus.FOUND) 29 | } 30 | 31 | export const spotifyCallback: Handler = async (c) => { 32 | const config = getConfig(c.env) 33 | const bodyParse = await c.req.json() 34 | const { platform, code } = authValidation.oauthCallback.parse(bodyParse) 35 | const request = await validateCallbackBody(c, code) 36 | const redirectUrl = config.oauth.platform[platform].redirectUrl 37 | const oauthRequest = spotify.users({ 38 | options: { 39 | clientId: config.oauth.provider.spotify.clientId, 40 | clientSecret: config.oauth.provider.spotify.clientSecret, 41 | redirectUrl: redirectUrl 42 | }, 43 | request 44 | }) as Promise<{ user: SpotifyUserType; tokens: unknown }> 45 | return oauthCallback(c, oauthRequest, authProviders.SPOTIFY) 46 | } 47 | 48 | export const linkSpotify: Handler = async (c) => { 49 | const config = getConfig(c.env) 50 | const bodyParse = await c.req.json() 51 | const { platform, code } = authValidation.oauthCallback.parse(bodyParse) 52 | const request = await validateCallbackBody(c, code) 53 | const redirectUrl = config.oauth.platform[platform].redirectUrl 54 | const oauthRequest = spotify.users({ 55 | options: { 56 | clientId: config.oauth.provider.spotify.clientId, 57 | clientSecret: config.oauth.provider.spotify.clientSecret, 58 | redirectUrl: redirectUrl 59 | }, 60 | request 61 | }) as Promise<{ user: SpotifyUserType; tokens: unknown }> 62 | return oauthLink(c, oauthRequest, authProviders.SPOTIFY) 63 | } 64 | 65 | export const deleteSpotifyLink: Handler = async (c) => { 66 | return deleteOauthLink(c, authProviders.SPOTIFY) 67 | } 68 | -------------------------------------------------------------------------------- /migrations/01_initial.ts: -------------------------------------------------------------------------------- 1 | import { Kysely, sql } from 'kysely' 2 | import { Database } from '../src/config/database' 3 | 4 | export async function up(db: Kysely) { 5 | await db.schema 6 | .createTable('user') 7 | .addColumn('id', 'varchar(21)', (col) => col.primaryKey()) 8 | .addColumn('name', 'varchar(255)') 9 | .addColumn('password', 'varchar(255)') 10 | .addColumn('email', 'varchar(255)', (col) => col.notNull().unique()) 11 | .addColumn('is_email_verified', 'boolean', (col) => col.defaultTo(false)) 12 | .addColumn('role', 'varchar(255)', (col) => col.defaultTo('user')) 13 | .addColumn('created_at', 'timestamp', (col) => col.defaultTo(sql`NOW()`)) 14 | .addColumn('updated_at', 'timestamp', (col) => { 15 | return col.defaultTo(sql`NOW()`).modifyEnd(sql`ON UPDATE NOW()`) 16 | }) 17 | .execute() 18 | 19 | await db.schema 20 | .createTable('authorisations') 21 | .addColumn('provider_type', 'varchar(255)', (col) => col.notNull()) 22 | .addColumn('provider_user_id', 'varchar(255)', (col) => col.notNull()) 23 | .addColumn('user_id', 'varchar(255)', (col) => col.notNull()) 24 | .addPrimaryKeyConstraint('primary_key', ['provider_type', 'provider_user_id', 'user_id']) 25 | .addUniqueConstraint('unique_provider_user', ['provider_type', 'provider_user_id']) 26 | .addColumn('created_at', 'timestamp', (col) => col.defaultTo(sql`NOW()`)) 27 | .addColumn('updated_at', 'timestamp', (col) => { 28 | return col.defaultTo(sql`NOW()`).modifyEnd(sql`ON UPDATE NOW()`) 29 | }) 30 | .execute() 31 | 32 | await db.schema.createIndex('user_email_index').on('user').column('email').execute() 33 | 34 | await db.schema 35 | .createIndex('authorisations_user_id_index') 36 | .on('authorisations') 37 | .column('user_id') 38 | .execute() 39 | 40 | await db.schema 41 | .createTable('one_time_oauth_code') 42 | .addColumn('code', 'varchar(255)', (col) => col.primaryKey()) 43 | .addColumn('user_id', 'varchar(255)', (col) => col.notNull()) 44 | .addColumn('access_token', 'varchar(255)', (col) => col.notNull()) 45 | .addColumn('access_token_expires_at', 'timestamp', (col) => col.notNull()) 46 | .addColumn('refresh_token', 'varchar(255)', (col) => col.notNull()) 47 | .addColumn('refresh_token_expires_at', 'timestamp', (col) => col.notNull()) 48 | .addColumn('expires_at', 'timestamp', (col) => col.notNull()) 49 | .addColumn('created_at', 'timestamp', (col) => col.defaultTo(sql`NOW()`)) 50 | .addColumn('updated_at', 'timestamp', (col) => { 51 | return col.defaultTo(sql`NOW()`).modifyEnd(sql`ON UPDATE NOW()`) 52 | }) 53 | .execute() 54 | } 55 | 56 | export async function down(db: Kysely) { 57 | await db.schema.dropTable('user').ifExists().execute() 58 | await db.schema.dropTable('authorisations').ifExists().execute() 59 | await db.schema.dropTable('one_time_oauth_code').ifExists().execute() 60 | } 61 | -------------------------------------------------------------------------------- /wrangler.toml.example: -------------------------------------------------------------------------------- 1 | name = 'cf-workers-hono-planetscale-app' 2 | main = 'dist/index.mjs' 3 | 4 | workers_dev = true 5 | compatibility_date = '2024-08-23' 6 | compatability_flags = [2nodejs_compat'] 7 | account_id='' 8 | 9 | [durable_objects] 10 | bindings = [ 11 | { name = 'RATE_LIMITER', class_name = 'RateLimiter' } 12 | ] 13 | 14 | [env.test.durable_objects] 15 | bindings = [ 16 | { name = 'RATE_LIMITER', class_name = 'RateLimiter' } 17 | ] 18 | 19 | [[migrations]] 20 | tag = 'v1' 21 | new_classes = ['RateLimiter'] 22 | 23 | [[env.test.migrations]] 24 | tag = 'v1' 25 | new_classes = ['RateLimiter'] 26 | 27 | [env.test.vars] 28 | ENV = 'development' 29 | JWT_ACCESS_EXPIRATION_MINUTES=30 30 | JWT_REFRESH_EXPIRATION_DAYS=30 31 | JWT_RESET_PASSWORD_EXPIRATION_MINUTES=15 32 | JWT_VERIFY_EMAIL_EXPIRATION_MINUTES=15 33 | DATABASE_NAME='example' 34 | DATABASE_USERNAME='example' 35 | DATABASE_HOST='example' 36 | AWS_REGION='eu-west-1' 37 | EMAIL_SENDER='noreply@gmail.com' 38 | OAUTH_GITHUB_CLIENT_ID='myclientid' 39 | OAUTH_DISCORD_CLIENT_ID='myclientid' 40 | OAUTH_DISCORD_REDIRECT_URL='https://frontend.com/login' 41 | OAUTH_SPOTIFY_CLIENT_ID='myclientid' 42 | OAUTH_SPOTIFY_REDIRECT_URL='https://frontend.com/login' 43 | OAUTH_GOOGLE_CLIENT_ID='myclientid' 44 | OAUTH_GOOGLE_REDIRECT_URL='https://frontend.com/login' 45 | OAUTH_FACEBOOK_CLIENT_ID='myclientid' 46 | OAUTH_FACEBOOK_REDIRECT_URL='https://frontend.com/login' 47 | OAUTH_APPLE_CLIENT_ID='com.your.app' 48 | OAUTH_APPLE_KEY_ID='randomid' 49 | OAUTH_APPLE_TEAM_ID='randomid' 50 | OAUTH_APPLE_JWT_ACCESS_EXPIRATION_MINUTES=30 51 | OAUTH_APPLE_REDIRECT_URL='https://api.com/v1/auth/apple/callback' 52 | 53 | 54 | [vars] 55 | ENV = 'development' 56 | JWT_ACCESS_EXPIRATION_MINUTES=30 57 | JWT_REFRESH_EXPIRATION_DAYS=30 58 | JWT_RESET_PASSWORD_EXPIRATION_MINUTES=15 59 | JWT_VERIFY_EMAIL_EXPIRATION_MINUTES=15 60 | DATABASE_NAME='example' 61 | DATABASE_USERNAME='example' 62 | DATABASE_HOST='example' 63 | AWS_REGION='eu-west-1' 64 | EMAIL_SENDER='noreply@gmail.com' 65 | OAUTH_GITHUB_CLIENT_ID='myclientid' 66 | OAUTH_DISCORD_CLIENT_ID='myclientid' 67 | OAUTH_DISCORD_REDIRECT_URL='https://frontend.com/login' 68 | OAUTH_SPOTIFY_CLIENT_ID='myclientid' 69 | OAUTH_SPOTIFY_REDIRECT_URL='https://frontend.com/login' 70 | OAUTH_GOOGLE_CLIENT_ID='myclientid' 71 | OAUTH_GOOGLE_REDIRECT_URL='https://frontend.com/login' 72 | OAUTH_FACEBOOK_CLIENT_ID='myclientid' 73 | OAUTH_FACEBOOK_REDIRECT_URL='https://frontend.com/login' 74 | OAUTH_APPLE_CLIENT_ID='com.your.app' 75 | OAUTH_APPLE_KEY_ID='randomid' 76 | OAUTH_APPLE_TEAM_ID='randomid' 77 | OAUTH_APPLE_JWT_ACCESS_EXPIRATION_MINUTES=30 78 | OAUTH_APPLE_REDIRECT_URL='https://api.com/v1/auth/apple/callback' 79 | 80 | [build] 81 | command = 'npm run build' 82 | # [secrets] 83 | # JWT_SECRET 84 | # DATABASE_PASSWORD 85 | # AWS_ACCESS_KEY_ID 86 | # AWS_SECRET_ACCESS_KEY 87 | # SENTRY_DSN 88 | # OAUTH_GITHUB_CLIENT_SECRET 89 | # OAUTH_DISCORD_CLIENT_SECRET 90 | # OAUTH_SPOTIFY_CLIENT_SECRET 91 | # OAUTH_GOOGLE_CLIENT_SECRET 92 | # OAUTH_FACEBOOK_CLIENT_SECRET 93 | # OAUTH_APPLE_PRIVATE_KEY 94 | -------------------------------------------------------------------------------- /scripts/migrate.ts: -------------------------------------------------------------------------------- 1 | /* eslint no-console: "off" */ 2 | import { promises as fs } from 'fs' 3 | import * as path from 'path' 4 | import { fileURLToPath } from 'url' 5 | import * as dotenv from 'dotenv' 6 | import { Migrator, FileMigrationProvider, NO_MIGRATIONS } from 'kysely' 7 | import { Kysely } from 'kysely' 8 | import { PlanetScaleDialect } from 'kysely-planetscale' 9 | import { User } from '../src/models/user.model' 10 | 11 | const envFile = { 12 | dev: '.env', 13 | test: '.env.test' 14 | } 15 | 16 | const __filename = fileURLToPath(import.meta.url) 17 | 18 | dotenv.config({ path: path.join(path.dirname(__filename), `../${envFile[process.argv[2]]}`) }) 19 | 20 | interface Database { 21 | user: User 22 | } 23 | 24 | const db = new Kysely({ 25 | dialect: new PlanetScaleDialect({ 26 | username: process.env.DATABASE_USERNAME, 27 | password: process.env.DATABASE_PASSWORD, 28 | host: process.env.DATABASE_HOST 29 | }) 30 | }) 31 | 32 | const migrator = new Migrator({ 33 | db, 34 | provider: new FileMigrationProvider({ 35 | fs, 36 | path, 37 | migrationFolder: path.join(path.dirname(__filename), '../migrations') 38 | }) 39 | }) 40 | 41 | async function migrateToLatest() { 42 | const { error, results } = await migrator.migrateToLatest() 43 | results?.forEach((it) => { 44 | if (it.status === 'Success') { 45 | console.log(`migration '${it.migrationName}' was executed successfully`) 46 | } else if (it.status === 'Error') { 47 | console.error(`failed to execute migration "${it.migrationName}"`) 48 | } 49 | }) 50 | 51 | if (error) { 52 | console.error('failed to migrate') 53 | console.error(error) 54 | process.exit(1) 55 | } 56 | 57 | await db.destroy() 58 | } 59 | 60 | async function migrateDown() { 61 | const { error, results } = await migrator.migrateDown() 62 | results?.forEach((it) => { 63 | if (it.status === 'Success') { 64 | console.log(`migration '${it.migrationName}' was reverted successfully`) 65 | } else if (it.status === 'Error') { 66 | console.error(`failed to execute migration "${it.migrationName}"`) 67 | } 68 | }) 69 | 70 | if (error) { 71 | console.error('failed to migrate') 72 | console.error(error) 73 | process.exit(1) 74 | } 75 | 76 | await db.destroy() 77 | } 78 | 79 | async function migrateNone() { 80 | const { error, results } = await migrator.migrateTo(NO_MIGRATIONS) 81 | results?.forEach((it) => { 82 | if (it.status === 'Success') { 83 | console.log(`migration '${it.migrationName}' was reverted successfully`) 84 | } else if (it.status === 'Error') { 85 | console.error(`failed to execute migration "${it.migrationName}"`) 86 | } 87 | }) 88 | 89 | if (error) { 90 | console.error('failed to migrate') 91 | console.error(error) 92 | process.exit(1) 93 | } 94 | 95 | await db.destroy() 96 | } 97 | 98 | const myArgs = process.argv[3] 99 | 100 | if (myArgs === 'down') { 101 | await migrateDown() 102 | } else if (myArgs === 'latest') { 103 | await migrateToLatest() 104 | } else if (myArgs === 'none') { 105 | await migrateNone() 106 | } 107 | -------------------------------------------------------------------------------- /src/routes/auth.route.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono' 2 | import { Environment } from '../../bindings' 3 | import * as authController from '../controllers/auth/auth.controller' 4 | import * as appleController from '../controllers/auth/oauth/apple.controller' 5 | import * as discordController from '../controllers/auth/oauth/discord.controller' 6 | import * as facebookController from '../controllers/auth/oauth/facebook.controller' 7 | import * as githubController from '../controllers/auth/oauth/github.controller' 8 | import * as googleController from '../controllers/auth/oauth/google.controller' 9 | import * as oauthController from '../controllers/auth/oauth/oauth.controller' 10 | import * as spotifyController from '../controllers/auth/oauth/spotify.controller' 11 | import { auth } from '../middlewares/auth' 12 | import { rateLimit } from '../middlewares/rate-limiter' 13 | 14 | export const route = new Hono() 15 | 16 | const twoMinutes = 120 17 | const oneRequest = 1 18 | 19 | route.post('/register', authController.register) 20 | route.post('/login', authController.login) 21 | route.post('/refresh-tokens', authController.refreshTokens) 22 | route.post('/forgot-password', authController.forgotPassword) 23 | route.post('/reset-password', authController.resetPassword) 24 | route.post( 25 | '/send-verification-email', 26 | auth(), 27 | rateLimit(twoMinutes, oneRequest), 28 | authController.sendVerificationEmail 29 | ) 30 | route.post('/verify-email', authController.verifyEmail) 31 | route.get('/authorisations', auth(), authController.getAuthorisations) 32 | 33 | route.get('/github/redirect', githubController.githubRedirect) 34 | route.get('/google/redirect', googleController.googleRedirect) 35 | route.get('/spotify/redirect', spotifyController.spotifyRedirect) 36 | route.get('/discord/redirect', discordController.discordRedirect) 37 | route.get('/facebook/redirect', facebookController.facebookRedirect) 38 | route.get('/apple/redirect', appleController.appleRedirect) 39 | 40 | route.post('/github/callback', githubController.githubCallback) 41 | route.post('/spotify/callback', spotifyController.spotifyCallback) 42 | route.post('/discord/callback', discordController.discordCallback) 43 | route.post('/google/callback', googleController.googleCallback) 44 | route.post('/facebook/callback', facebookController.facebookCallback) 45 | route.post('/apple/callback', appleController.appleCallback) 46 | 47 | route.post('/github/:userId', auth('manageUsers'), githubController.linkGithub) 48 | route.post('/spotify/:userId', auth('manageUsers'), spotifyController.linkSpotify) 49 | route.post('/discord/:userId', auth('manageUsers'), discordController.linkDiscord) 50 | route.post('/google/:userId', auth('manageUsers'), googleController.linkGoogle) 51 | route.post('/facebook/:userId', auth('manageUsers'), facebookController.linkFacebook) 52 | route.post('/apple/:userId', auth('manageUsers'), appleController.linkApple) 53 | 54 | route.delete('/github/:userId', auth('manageUsers'), githubController.deleteGithubLink) 55 | route.delete('/spotify/:userId', auth('manageUsers'), spotifyController.deleteSpotifyLink) 56 | route.delete('/discord/:userId', auth('manageUsers'), discordController.deleteDiscordLink) 57 | route.delete('/google/:userId', auth('manageUsers'), googleController.deleteGoogleLink) 58 | route.delete('/facebook/:userId', auth('manageUsers'), facebookController.deleteFacebookLink) 59 | route.delete('/apple/:userId', auth('manageUsers'), appleController.deleteAppleLink) 60 | 61 | route.post('/validate', oauthController.validateOauthOneTimeCode) 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-cf-planetscale-app", 3 | "version": "3.0.0", 4 | "description": "Create a Cloudflare workers app for building production ready RESTful APIs using Hono", 5 | "main": "dist/index.mjs", 6 | "engines": { 7 | "node": ">=12.0.0" 8 | }, 9 | "bin": "bin/createApp.js", 10 | "repository": "https://github.com/OultimoCoder/cloudflare-planetscale-hono-boilerplate.git", 11 | "author": "Ben Louis Armstrong ", 12 | "license": "MIT", 13 | "keywords": [ 14 | "cloudflare", 15 | "workers", 16 | "cloudflare-worker", 17 | "cloudflare-workers", 18 | "planetscale", 19 | "boilerplate", 20 | "template", 21 | "starter", 22 | "example", 23 | "vitest", 24 | "hono", 25 | "api", 26 | "rest", 27 | "sql", 28 | "oauth", 29 | "jwt", 30 | "es6", 31 | "es7", 32 | "es8", 33 | "es9", 34 | "jwt", 35 | "zod", 36 | "eslint", 37 | "prettier" 38 | ], 39 | "scripts": { 40 | "build": "node ./build.js", 41 | "dev": "wrangler dev dist/index.mjs --live-reload --port 8787", 42 | "tests": "npm run build && vitest run", 43 | "tests:coverage": "npm run build && vitest run --coverage --coverage.provider istanbul --coverage.include src/", 44 | "migrate:test:latest": "node --experimental-specifier-resolution=node --loader ts-node/esm ./scripts/migrate.ts test latest", 45 | "migrate:test:none": "node --experimental-specifier-resolution=node --loader ts-node/esm ./scripts/migrate.ts test none", 46 | "migrate:test:down": "node --experimental-specifier-resolution=node --loader ts-node/esm ./scripts/migrate.ts test down", 47 | "lint": "eslint .", 48 | "lint:fix": "eslint . --fix", 49 | "prettier": "prettier --check **/*.ts", 50 | "prettier:fix": "prettier --write **/**/*.ts", 51 | "prepare": "husky", 52 | "deploy": "wrangler publish" 53 | }, 54 | "type": "module", 55 | "devDependencies": { 56 | "@cloudflare/vitest-pool-workers": "^0.4.25", 57 | "@cloudflare/workers-types": "^4.20240821.1", 58 | "@faker-js/faker": "^8.4.1", 59 | "@types/bcryptjs": "^2.4.6", 60 | "@types/eslint__js": "^8.42.3", 61 | "@typescript-eslint/parser": "^8.2.0", 62 | "cross-env": "^7.0.3", 63 | "dotenv": "^16.4.5", 64 | "esbuild": "^0.23.1", 65 | "eslint": "^9.9.0", 66 | "eslint-config-prettier": "^9.1.0", 67 | "eslint-plugin-import-x": "^3.1.0", 68 | "eslint-plugin-node": "^11.1.0", 69 | "eslint-plugin-prettier": "^5.2.1", 70 | "eslint-plugin-vitest": "^0.5.4", 71 | "globals": "^15.9.0", 72 | "husky": "^9.1.5", 73 | "mockdate": "^3.0.5", 74 | "ts-node": "^10.9.2", 75 | "typescript": "^5.5.4", 76 | "typescript-eslint": "^8.2.0", 77 | "vitest": "1.5.0", 78 | "wrangler": "^3.72.2" 79 | }, 80 | "dependencies": { 81 | "@aws-sdk/client-ses": "^3.637.0", 82 | "@hono/sentry": "^1.2.0", 83 | "@planetscale/database": "^1.19.0", 84 | "@smithy/types": "^3.3.0", 85 | "@tsndr/cloudflare-worker-jwt": "2.5.3", 86 | "@vitest/coverage-istanbul": "^1.5.0", 87 | "bcryptjs": "^2.4.3", 88 | "dayjs": "^1.11.13", 89 | "hono": "^4.5.8", 90 | "http-status": "^1.7.4", 91 | "kysely": "^0.27.4", 92 | "kysely-planetscale": "^1.5", 93 | "nanoid": "^5.0.7", 94 | "toucan-js": "4.0.0", 95 | "worker-auth-providers": "^0.0.13", 96 | "zod": "^3.23.8", 97 | "zod-validation-error": "^3.3.1" 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/controllers/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Handler } from 'hono' 2 | import httpStatus from 'http-status' 3 | import { Environment } from '../../../bindings' 4 | import { getConfig } from '../../config/config' 5 | import * as authService from '../../services/auth.service' 6 | import * as emailService from '../../services/email.service' 7 | import * as tokenService from '../../services/token.service' 8 | import * as userService from '../../services/user.service' 9 | import { ApiError } from '../../utils/api-error' 10 | import * as authValidation from '../../validations/auth.validation' 11 | 12 | export const register: Handler = async (c) => { 13 | const config = getConfig(c.env) 14 | const bodyParse = await c.req.json() 15 | const body = await authValidation.register.parseAsync(bodyParse) 16 | const user = await authService.register(body, config.database) 17 | const tokens = await tokenService.generateAuthTokens(user, config.jwt) 18 | return c.json({ user, tokens }, httpStatus.CREATED) 19 | } 20 | 21 | export const login: Handler = async (c) => { 22 | const config = getConfig(c.env) 23 | const bodyParse = await c.req.json() 24 | const { email, password } = authValidation.login.parse(bodyParse) 25 | const user = await authService.loginUserWithEmailAndPassword(email, password, config.database) 26 | const tokens = await tokenService.generateAuthTokens(user, config.jwt) 27 | return c.json({ user, tokens }, httpStatus.OK) 28 | } 29 | 30 | export const refreshTokens: Handler = async (c) => { 31 | const config = getConfig(c.env) 32 | const bodyParse = await c.req.json() 33 | const { refresh_token } = authValidation.refreshTokens.parse(bodyParse) 34 | const tokens = await authService.refreshAuth(refresh_token, config) 35 | return c.json({ ...tokens }, httpStatus.OK) 36 | } 37 | 38 | export const forgotPassword: Handler = async (c) => { 39 | const bodyParse = await c.req.json() 40 | const config = getConfig(c.env) 41 | const { email } = authValidation.forgotPassword.parse(bodyParse) 42 | const user = await userService.getUserByEmail(email, config.database) 43 | // Don't let bad actors know if the email is registered by throwing if the user exists 44 | if (user) { 45 | const resetPasswordToken = await tokenService.generateResetPasswordToken(user, config.jwt) 46 | await emailService.sendResetPasswordEmail( 47 | user.email, 48 | { name: user.name || '', token: resetPasswordToken }, 49 | config 50 | ) 51 | } 52 | c.status(httpStatus.NO_CONTENT) 53 | return c.body(null) 54 | } 55 | 56 | export const resetPassword: Handler = async (c) => { 57 | const queryParse = c.req.query() 58 | const bodyParse = await c.req.json() 59 | const config = getConfig(c.env) 60 | const { query, body } = await authValidation.resetPassword.parseAsync({ 61 | query: queryParse, 62 | body: bodyParse 63 | }) 64 | await authService.resetPassword(query.token, body.password, config) 65 | c.status(httpStatus.NO_CONTENT) 66 | return c.body(null) 67 | } 68 | 69 | export const sendVerificationEmail: Handler = async (c) => { 70 | const config = getConfig(c.env) 71 | const payload = c.get('payload') 72 | const userId = payload.sub 73 | if (!userId) { 74 | throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate') 75 | } 76 | // Don't let bad actors know if the email is registered by returning an error if the email 77 | // is already verified 78 | try { 79 | const user = await userService.getUserById(userId, config.database) 80 | if (!user || user.is_email_verified) { 81 | throw new Error() 82 | } 83 | const verifyEmailToken = await tokenService.generateVerifyEmailToken(user, config.jwt) 84 | await emailService.sendVerificationEmail( 85 | user.email, 86 | { name: user.name || '', token: verifyEmailToken }, 87 | config 88 | ) 89 | } catch {} 90 | c.status(httpStatus.NO_CONTENT) 91 | return c.body(null) 92 | } 93 | 94 | export const verifyEmail: Handler = async (c) => { 95 | const config = getConfig(c.env) 96 | const queryParse = c.req.query() 97 | const { token } = authValidation.verifyEmail.parse(queryParse) 98 | await authService.verifyEmail(token, config) 99 | c.status(httpStatus.NO_CONTENT) 100 | return c.body(null) 101 | } 102 | 103 | export const getAuthorisations: Handler = async (c) => { 104 | const config = getConfig(c.env) 105 | const payload = c.get('payload') 106 | if (!payload.sub) { 107 | throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate') 108 | } 109 | const userId = payload.sub 110 | const authorisations = await userService.getAuthorisations(userId, config.database) 111 | return c.json(authorisations, httpStatus.OK) 112 | } 113 | -------------------------------------------------------------------------------- /src/controllers/auth/oauth/oauth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Context, Handler } from 'hono' 2 | import type { StatusCode } from 'hono/utils/http-status' 3 | import httpStatus from 'http-status' 4 | import { Environment } from '../../../../bindings' 5 | import { Config, getConfig } from '../../../config/config' 6 | import { providerUserFactory } from '../../../factories/oauth.factory' 7 | import { OAuthUserModel } from '../../../models/oauth/oauth-base.model' 8 | import * as authService from '../../../services/auth.service' 9 | import * as tokenService from '../../../services/token.service' 10 | import * as userService from '../../../services/user.service' 11 | import { AuthProviderType, OauthUserTypes } from '../../../types/oauth.types' 12 | import { ApiError } from '../../../utils/api-error' 13 | import * as authValidation from '../../../validations/auth.validation' 14 | 15 | type State = { 16 | platform: 'web' | 'android' | 'ios' 17 | } 18 | 19 | export const parseState = (state: string) => { 20 | try { 21 | const decodedState = JSON.parse(atob(state)) as State 22 | authValidation.stateValidation.parse(decodedState) 23 | return decodedState 24 | } catch { 25 | throw new ApiError(httpStatus.BAD_REQUEST as StatusCode, 'Bad request') 26 | } 27 | } 28 | 29 | export const getRedirectUrl = (state: string, config: Config) => { 30 | try { 31 | const decodedState = parseState(state) 32 | const platform = decodedState.platform 33 | return config.oauth.platform[platform].redirectUrl 34 | } catch { 35 | throw new ApiError(httpStatus.BAD_REQUEST as StatusCode, 'Bad request') 36 | } 37 | } 38 | 39 | export const oauthCallback = async ( 40 | c: Context, 41 | oauthRequest: Promise<{ user: OauthUserTypes[T]; tokens: unknown }>, 42 | providerType: T 43 | ): Promise => { 44 | const config = getConfig(c.env) 45 | let providerUser: OAuthUserModel 46 | try { 47 | const result = await oauthRequest 48 | const UserModel = providerUserFactory[providerType] 49 | providerUser = new UserModel(result.user) 50 | } catch { 51 | throw new ApiError(httpStatus.UNAUTHORIZED as StatusCode, 'Unauthorized') 52 | } 53 | const user = await authService.loginOrCreateUserWithOauth(providerUser, config.database) 54 | const tokens = await tokenService.generateAuthTokens(user, config.jwt) 55 | return c.json({ user, tokens }, httpStatus.OK as StatusCode) 56 | } 57 | 58 | export const oauthLink = async ( 59 | c: Context, 60 | oauthRequest: Promise<{ user: OauthUserTypes[T]; tokens: unknown }>, 61 | providerType: T 62 | ): Promise => { 63 | const payload = c.get('payload') 64 | const userId = payload.sub 65 | if (!userId) { 66 | throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate') 67 | } 68 | const config = getConfig(c.env) 69 | let providerUser: OAuthUserModel 70 | try { 71 | const result = await oauthRequest 72 | const UserModel = providerUserFactory[providerType] 73 | providerUser = new UserModel(result.user) 74 | } catch { 75 | throw new ApiError(httpStatus.UNAUTHORIZED as StatusCode, 'Unauthorized') 76 | } 77 | await authService.linkUserWithOauth(userId, providerUser, config.database) 78 | c.status(httpStatus.NO_CONTENT as StatusCode) 79 | return c.body(null) 80 | } 81 | 82 | export const deleteOauthLink = async ( 83 | c: Context, 84 | provider: AuthProviderType 85 | ): Promise => { 86 | const payload = c.get('payload') 87 | const userId = payload.sub 88 | if (!userId) { 89 | throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate') 90 | } 91 | const config = getConfig(c.env) 92 | await authService.deleteOauthLink(userId, provider, config.database) 93 | c.status(httpStatus.NO_CONTENT as StatusCode) 94 | return c.body(null) 95 | } 96 | 97 | export const validateCallbackBody = async ( 98 | c: Context, 99 | code: string 100 | ): Promise => { 101 | const url = new URL(c.req.url) 102 | url.searchParams.set('code', code) 103 | const request = new Request(url.toString()) 104 | return request 105 | } 106 | 107 | export const validateOauthOneTimeCode: Handler = async (c) => { 108 | const config = getConfig(c.env) 109 | const bodyParse = await c.req.json() 110 | const { code } = authValidation.validateOneTimeCode.parse(bodyParse) 111 | const oauthCode = await tokenService.getOneTimeOauthCode(code, config) 112 | const user = await userService.getUserById(oauthCode.user_id, config.database) 113 | const tokenResponse = { 114 | access: { 115 | token: oauthCode.access_token, 116 | expires: oauthCode.access_token_expires_at 117 | }, 118 | refresh: { 119 | token: oauthCode.refresh_token, 120 | expires: oauthCode.refresh_token_expires_at 121 | } 122 | } 123 | return c.json({ user, tokens: tokenResponse }, httpStatus.OK as StatusCode) 124 | } 125 | -------------------------------------------------------------------------------- /src/controllers/auth/oauth/apple.controller.ts: -------------------------------------------------------------------------------- 1 | // TODO: Handle users using private email relay 2 | // https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/ 3 | // authenticating_users_with_sign_in_with_apple 4 | // Also handle users without email 5 | // refactor 6 | import { decode } from '@tsndr/cloudflare-worker-jwt' 7 | import { Handler } from 'hono' 8 | import type { StatusCode } from 'hono/utils/http-status' 9 | import httpStatus from 'http-status' 10 | import { apple } from 'worker-auth-providers' 11 | import { Environment } from '../../../../bindings' 12 | import { authProviders } from '../../../config/authProviders' 13 | import { Config, getConfig } from '../../../config/config' 14 | import { AppleUser } from '../../../models/oauth/apple-user.model' 15 | import * as authService from '../../../services/auth.service' 16 | import { getIdTokenFromCode } from '../../../services/oauth/apple.service' 17 | import * as tokenService from '../../../services/token.service' 18 | import { ApiError } from '../../../utils/api-error' 19 | import * as authValidation from '../../../validations/auth.validation' 20 | import { deleteOauthLink, getRedirectUrl, parseState } from './oauth.controller' 21 | 22 | type AppleJWT = { 23 | iss: string 24 | aud: string 25 | exp: number 26 | iat: number 27 | sub: string 28 | at_hash: string 29 | email: string 30 | email_verified: string 31 | is_private_email: string 32 | auth_time: number 33 | nonce_supported: boolean 34 | } 35 | 36 | const getAppleUser = async (code: string | null, config: Config) => { 37 | if (!code) { 38 | throw new ApiError(httpStatus.BAD_REQUEST as StatusCode, 'Bad request') 39 | } 40 | const appleClientSecret = await apple.convertPrivateKeyToClientSecret({ 41 | privateKey: config.oauth.provider.apple.privateKey, 42 | keyIdentifier: config.oauth.provider.apple.keyId, 43 | teamId: config.oauth.provider.apple.teamId, 44 | clientId: config.oauth.provider.apple.clientId, 45 | expAfter: config.oauth.provider.apple.jwtAccessExpirationMinutes * 60 46 | }) 47 | const idToken = await getIdTokenFromCode( 48 | code, 49 | config.oauth.provider.apple.clientId, 50 | appleClientSecret, 51 | config.oauth.provider.apple.redirectUrl 52 | ) 53 | const userData = decode(idToken).payload as AppleJWT 54 | if (!userData.email) { 55 | throw new ApiError(httpStatus.UNAUTHORIZED, 'Unauthorized') 56 | } 57 | const appleUser = new AppleUser(userData) 58 | return appleUser 59 | } 60 | 61 | export const appleRedirect: Handler = async (c) => { 62 | const config = getConfig(c.env) 63 | const { state } = authValidation.oauthRedirect.parse(c.req.query()) 64 | parseState(state) 65 | const location = await apple.redirect({ 66 | options: { 67 | clientId: config.oauth.provider.apple.clientId, 68 | redirectTo: config.oauth.provider.apple.redirectUrl, 69 | scope: ['email'], 70 | responseMode: 'form_post', 71 | state: state 72 | } 73 | }) 74 | return c.redirect(location, httpStatus.FOUND) 75 | } 76 | 77 | export const appleCallback: Handler = async (c) => { 78 | const config = getConfig(c.env) 79 | const formData = await c.req.formData() 80 | const state = formData.get('state') 81 | if (!state) { 82 | const redirect = new URL('?error=Something went wrong', config.oauth.platform.web.redirectUrl) 83 | .href 84 | return c.redirect(redirect, httpStatus.FOUND) 85 | } 86 | // Set a base redirect url to web in case of no platform info being passed 87 | let redirectBase = config.oauth.platform.web.redirectUrl 88 | try { 89 | redirectBase = getRedirectUrl(state, config) 90 | const appleUser = await getAppleUser(formData.get('code'), config) 91 | const user = await authService.loginOrCreateUserWithOauth(appleUser, config.database) 92 | const tokens = await tokenService.generateAuthTokens(user, config.jwt) 93 | const oneTimeCode = await tokenService.createOneTimeOauthCode(user.id, tokens, config) 94 | const redirect = new URL(`?oneTimeCode=${oneTimeCode}&state=${state}`, redirectBase).href 95 | return c.redirect(redirect, httpStatus.FOUND) 96 | } catch (error) { 97 | const message = error instanceof ApiError ? error.message : 'Something went wrong' 98 | const redirect = new URL(`?error=${message}&state=${state}`, redirectBase).href 99 | return c.redirect(redirect, httpStatus.FOUND) 100 | } 101 | } 102 | 103 | export const linkApple: Handler = async (c) => { 104 | const config = getConfig(c.env) 105 | const payload = c.get('payload') 106 | const userId = payload.sub 107 | if (!userId) { 108 | throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate') 109 | } 110 | const bodyParse = await c.req.json() 111 | const { code } = authValidation.linkApple.parse(bodyParse) 112 | const appleUser = await getAppleUser(code, config) 113 | await authService.linkUserWithOauth(userId, appleUser, config.database) 114 | c.status(httpStatus.NO_CONTENT as StatusCode) 115 | return c.body(null) 116 | } 117 | 118 | export const deleteAppleLink: Handler = async (c) => { 119 | return deleteOauthLink(c, authProviders.APPLE) 120 | } 121 | -------------------------------------------------------------------------------- /src/durable-objects/rate-limiter.do.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | import { Context, Hono } from 'hono' 3 | import httpStatus from 'http-status' 4 | import { z, ZodError } from 'zod' 5 | import { fromError } from 'zod-validation-error' 6 | import { Environment } from '../../bindings' 7 | 8 | interface Config { 9 | scope: string 10 | key: string 11 | limit: number 12 | interval: number 13 | } 14 | 15 | const configValidation = z.object({ 16 | scope: z.string(), 17 | key: z.string(), 18 | limit: z.number().int().positive(), 19 | interval: z.number().int().positive() 20 | }) 21 | 22 | export class RateLimiter { 23 | state: DurableObjectState 24 | env: Environment['Bindings'] 25 | app: Hono = new Hono() 26 | 27 | constructor(state: DurableObjectState, env: Environment['Bindings']) { 28 | this.state = state 29 | this.env = env 30 | 31 | this.app.post('/', async (c) => { 32 | await this.setAlarm() 33 | let config 34 | try { 35 | config = await this.getConfig(c) 36 | } catch (err: unknown) { 37 | let errorMessage 38 | if (err instanceof ZodError) { 39 | errorMessage = fromError(err) 40 | } 41 | return c.json( 42 | { 43 | statusCode: httpStatus.BAD_REQUEST, 44 | error: errorMessage 45 | }, 46 | httpStatus.BAD_REQUEST 47 | ) 48 | } 49 | const rate = await this.calculateRate(config) 50 | const blocked = this.isRateLimited(rate, config.limit) 51 | const headers = this.getHeaders(blocked, config) 52 | const remaining = blocked ? 0 : Math.floor(config.limit - rate - 1) 53 | // If the remaining requests is negative set it to 0 to indicate 100% throughput 54 | const remainingHeader = remaining >= 0 ? remaining : 0 55 | return c.json( 56 | { 57 | blocked, 58 | remaining: remainingHeader, 59 | expires: headers.expires 60 | }, 61 | httpStatus.OK, 62 | headers 63 | ) 64 | }) 65 | } 66 | 67 | async alarm() { 68 | const values = await this.state.storage.list() 69 | for await (const [key, _value] of values) { 70 | const [_scope, _key, _limit, interval, timestamp] = key.split('|') 71 | const currentWindow = Math.floor(this.nowUnix() / parseInt(interval)) 72 | const timestampLessThan = currentWindow - 2 // expire all keys after 2 intervals have passed 73 | if (parseInt(timestamp) < timestampLessThan) { 74 | await this.state.storage.delete(key) 75 | } 76 | } 77 | } 78 | 79 | async setAlarm() { 80 | const alarm = await this.state.storage.getAlarm() 81 | if (!alarm) { 82 | this.state.storage.setAlarm(dayjs().add(6, 'hours').toDate()) 83 | } 84 | } 85 | 86 | async getConfig(c: Context) { 87 | const body = await c.req.json() 88 | const config = configValidation.parse(body) 89 | return config 90 | } 91 | 92 | async incrementRequestCount(key: string) { 93 | const currentRequestCount = await this.getRequestCount(key) 94 | await this.state.storage.put(key, currentRequestCount + 1) 95 | } 96 | 97 | async getRequestCount(key: string): Promise { 98 | return parseInt((await this.state.storage.get(key)) as string) || 0 99 | } 100 | 101 | nowUnix() { 102 | return dayjs().unix() 103 | } 104 | 105 | async calculateRate(config: Config) { 106 | const keyPrefix = `${config.scope}|${config.key}|${config.limit}|${config.interval}` 107 | const currentWindow = Math.floor(this.nowUnix() / config.interval) 108 | const distanceFromLastWindow = this.nowUnix() % config.interval 109 | const currentKey = `${keyPrefix}|${currentWindow}` 110 | const previousKey = `${keyPrefix}|${currentWindow - 1}` 111 | const currentCount = await this.getRequestCount(currentKey) 112 | const previousCount = (await this.getRequestCount(previousKey)) || 0 113 | const rate = 114 | (previousCount * (config.interval - distanceFromLastWindow)) / config.interval + currentCount 115 | if (!this.isRateLimited(rate, config.limit)) { 116 | await this.incrementRequestCount(currentKey) 117 | } 118 | return rate 119 | } 120 | 121 | isRateLimited(rate: number, limit: number) { 122 | return rate >= limit 123 | } 124 | 125 | getHeaders(blocked: boolean, config: Config) { 126 | const expires = this.expirySeconds(config) 127 | const retryAfter = this.retryAfter(expires) 128 | const headers: { expires: string; 'cache-control'?: string } = { 129 | expires: retryAfter.toString() 130 | } 131 | if (!blocked) { 132 | return headers 133 | } 134 | headers['cache-control'] = `public, max-age=${expires}, s-maxage=${expires}, must-revalidate` 135 | return headers 136 | } 137 | 138 | expirySeconds(config: Config) { 139 | const currentWindowStart = Math.floor(this.nowUnix() / config.interval) 140 | const currentWindowEnd = currentWindowStart + 1 141 | const secondsRemaining = currentWindowEnd * config.interval - this.nowUnix() 142 | return secondsRemaining 143 | } 144 | 145 | retryAfter(expires: number) { 146 | return dayjs().add(expires, 'seconds').toString() 147 | } 148 | 149 | async fetch(request: Request): Promise { 150 | return this.app.fetch(request) 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/services/token.service.ts: -------------------------------------------------------------------------------- 1 | import jwt, { JwtPayload } from '@tsndr/cloudflare-worker-jwt' 2 | import dayjs, { Dayjs } from 'dayjs' 3 | import httpStatus from 'http-status' 4 | import { Selectable } from 'kysely' 5 | import { Config } from '../config/config' 6 | import { getDBClient } from '../config/database' 7 | import { Role } from '../config/roles' 8 | import { TokenType, tokenTypes } from '../config/tokens' 9 | import { OneTimeOauthCode } from '../models/one-time-oauth-code' 10 | import { TokenResponse } from '../models/token.model' 11 | import { User } from '../models/user.model' 12 | import { ApiError } from '../utils/api-error' 13 | import { generateId } from '../utils/utils' 14 | 15 | export const generateToken = async ( 16 | userId: string, 17 | type: TokenType, 18 | role: Role, 19 | expires: Dayjs, 20 | secret: string, 21 | isEmailVerified: boolean 22 | ) => { 23 | const payload = { 24 | sub: userId.toString(), 25 | exp: expires.unix(), 26 | iat: dayjs().unix(), 27 | type, 28 | role, 29 | isEmailVerified 30 | } 31 | return jwt.sign(payload, secret) 32 | } 33 | 34 | export const generateAuthTokens = async (user: Selectable, jwtConfig: Config['jwt']) => { 35 | const accessTokenExpires = dayjs().add(jwtConfig.accessExpirationMinutes, 'minutes') 36 | const accessToken = await generateToken( 37 | user.id, 38 | tokenTypes.ACCESS, 39 | user.role, 40 | accessTokenExpires, 41 | jwtConfig.secret, 42 | user.is_email_verified 43 | ) 44 | const refreshTokenExpires = dayjs().add(jwtConfig.refreshExpirationDays, 'days') 45 | const refreshToken = await generateToken( 46 | user.id, 47 | tokenTypes.REFRESH, 48 | user.role, 49 | refreshTokenExpires, 50 | jwtConfig.secret, 51 | user.is_email_verified 52 | ) 53 | return { 54 | access: { 55 | token: accessToken, 56 | expires: accessTokenExpires.toDate() 57 | }, 58 | refresh: { 59 | token: refreshToken, 60 | expires: refreshTokenExpires.toDate() 61 | } 62 | } 63 | } 64 | 65 | export const verifyToken = async (token: string, type: TokenType, secret: string) => { 66 | const isValid = await jwt.verify(token, secret) 67 | if (!isValid) { 68 | throw new Error('Token not valid') 69 | } 70 | const decoded = jwt.decode(token) 71 | const payload = decoded.payload as JwtPayload 72 | if (type !== payload.type) { 73 | throw new Error('Token not valid') 74 | } 75 | return payload 76 | } 77 | 78 | export const generateVerifyEmailToken = async ( 79 | user: Selectable, 80 | jwtConfig: Config['jwt'] 81 | ) => { 82 | const expires = dayjs().add(jwtConfig.verifyEmailExpirationMinutes, 'minutes') 83 | const verifyEmailToken = await generateToken( 84 | user.id, 85 | tokenTypes.VERIFY_EMAIL, 86 | user.role, 87 | expires, 88 | jwtConfig.secret, 89 | user.is_email_verified 90 | ) 91 | return verifyEmailToken 92 | } 93 | 94 | export const generateResetPasswordToken = async ( 95 | user: Selectable, 96 | jwtConfig: Config['jwt'] 97 | ) => { 98 | const expires = dayjs().add(jwtConfig.resetPasswordExpirationMinutes, 'minutes') 99 | const resetPasswordToken = await generateToken( 100 | user.id, 101 | tokenTypes.RESET_PASSWORD, 102 | user.role, 103 | expires, 104 | jwtConfig.secret, 105 | user.is_email_verified 106 | ) 107 | return resetPasswordToken 108 | } 109 | 110 | const generateOneTimeOauthCodeToken = () => { 111 | return generateId() 112 | } 113 | 114 | export const createOneTimeOauthCode = async ( 115 | userId: string, 116 | tokens: TokenResponse, 117 | config: Config 118 | ) => { 119 | const db = getDBClient(config.database) 120 | let attempts = 0 121 | const maxAttempts = 5 122 | let code = generateOneTimeOauthCodeToken() 123 | while (attempts < maxAttempts) { 124 | try { 125 | await db 126 | .insertInto('one_time_oauth_code') 127 | .values({ 128 | code, 129 | user_id: userId, 130 | access_token: tokens.access.token, 131 | access_token_expires_at: tokens.access.expires, 132 | refresh_token: tokens.refresh.token, 133 | refresh_token_expires_at: tokens.refresh.expires, 134 | expires_at: dayjs().add(config.jwt.accessExpirationMinutes, 'minutes').toDate() 135 | }) 136 | .executeTakeFirstOrThrow() 137 | break 138 | } catch { 139 | if (attempts >= maxAttempts) { 140 | throw new ApiError(httpStatus.INTERNAL_SERVER_ERROR, 'Failed to create one time code') 141 | } 142 | code = generateOneTimeOauthCodeToken() 143 | attempts++ 144 | } 145 | } 146 | return code 147 | } 148 | 149 | export const getOneTimeOauthCode = async (code: string, config: Config) => { 150 | const db = getDBClient(config.database) 151 | const oneTimeCode = await db.transaction().execute(async (trx) => { 152 | const oneTimeCode = await db 153 | .selectFrom('one_time_oauth_code') 154 | .selectAll() 155 | .where('code', '=', code) 156 | .where('expires_at', '>', dayjs().toDate()) 157 | .executeTakeFirst() 158 | if (!oneTimeCode) { 159 | throw new ApiError(httpStatus.BAD_REQUEST, 'Code invalid or expired') 160 | } 161 | await trx.deleteFrom('one_time_oauth_code').where('code', '=', code).execute() 162 | return oneTimeCode 163 | }) 164 | return new OneTimeOauthCode(oneTimeCode) 165 | } 166 | -------------------------------------------------------------------------------- /src/config/config.ts: -------------------------------------------------------------------------------- 1 | import httpStatus from 'http-status' 2 | import { ZodError, z } from 'zod' 3 | import { Environment } from '../../bindings' 4 | import { ApiError } from '../utils/api-error' 5 | import { generateZodErrorMessage } from '../utils/zod' 6 | 7 | const envVarsSchema = z.object({ 8 | ENV: z.union([z.literal('production'), z.literal('development'), z.literal('test')]), 9 | DATABASE_NAME: z.string(), 10 | DATABASE_USERNAME: z.string(), 11 | DATABASE_PASSWORD: z.string(), 12 | DATABASE_HOST: z.string(), 13 | JWT_SECRET: z.string(), 14 | JWT_ACCESS_EXPIRATION_MINUTES: z.coerce.number().default(30), 15 | JWT_REFRESH_EXPIRATION_DAYS: z.coerce.number().default(30), 16 | JWT_RESET_PASSWORD_EXPIRATION_MINUTES: z.coerce.number().default(10), 17 | JWT_VERIFY_EMAIL_EXPIRATION_MINUTES: z.coerce.number().default(10), 18 | AWS_ACCESS_KEY_ID: z.string(), 19 | AWS_SECRET_ACCESS_KEY: z.string(), 20 | AWS_REGION: z.string(), 21 | EMAIL_SENDER: z.string(), 22 | OAUTH_WEB_REDIRECT_URL: z.string(), 23 | OAUTH_ANDROID_REDIRECT_URL: z.string(), 24 | OAUTH_IOS_REDIRECT_URL: z.string(), 25 | OAUTH_GITHUB_CLIENT_ID: z.string(), 26 | OAUTH_GITHUB_CLIENT_SECRET: z.string(), 27 | OAUTH_GOOGLE_CLIENT_ID: z.string(), 28 | OAUTH_GOOGLE_CLIENT_SECRET: z.string(), 29 | OAUTH_DISCORD_CLIENT_ID: z.string(), 30 | OAUTH_DISCORD_CLIENT_SECRET: z.string(), 31 | OAUTH_SPOTIFY_CLIENT_ID: z.string(), 32 | OAUTH_SPOTIFY_CLIENT_SECRET: z.string(), 33 | OAUTH_FACEBOOK_CLIENT_ID: z.string(), 34 | OAUTH_FACEBOOK_CLIENT_SECRET: z.string(), 35 | OAUTH_APPLE_CLIENT_ID: z.string(), 36 | OAUTH_APPLE_PRIVATE_KEY: z.string(), 37 | OAUTH_APPLE_KEY_ID: z.string(), 38 | OAUTH_APPLE_TEAM_ID: z.string(), 39 | OAUTH_APPLE_JWT_ACCESS_EXPIRATION_MINUTES: z.coerce.number().default(30), 40 | OAUTH_APPLE_REDIRECT_URL: z.string() 41 | }) 42 | 43 | export type EnvVarsSchemaType = z.infer 44 | 45 | export interface Config { 46 | env: 'production' | 'development' | 'test' 47 | database: { 48 | name: string 49 | username: string 50 | password: string 51 | host: string 52 | } 53 | jwt: { 54 | secret: string 55 | accessExpirationMinutes: number 56 | refreshExpirationDays: number 57 | resetPasswordExpirationMinutes: number 58 | verifyEmailExpirationMinutes: number 59 | } 60 | aws: { 61 | accessKeyId: string 62 | secretAccessKey: string 63 | region: string 64 | } 65 | email: { 66 | sender: string 67 | } 68 | oauth: { 69 | platform: { 70 | web: { 71 | redirectUrl: string 72 | } 73 | android: { 74 | redirectUrl: string 75 | } 76 | ios: { 77 | redirectUrl: string 78 | } 79 | } 80 | provider: { 81 | github: { 82 | clientId: string 83 | clientSecret: string 84 | } 85 | google: { 86 | clientId: string 87 | clientSecret: string 88 | } 89 | spotify: { 90 | clientId: string 91 | clientSecret: string 92 | } 93 | discord: { 94 | clientId: string 95 | clientSecret: string 96 | } 97 | facebook: { 98 | clientId: string 99 | clientSecret: string 100 | } 101 | apple: { 102 | clientId: string 103 | privateKey: string 104 | keyId: string 105 | teamId: string 106 | jwtAccessExpirationMinutes: number 107 | redirectUrl: string 108 | } 109 | } 110 | } 111 | } 112 | 113 | let config: Config 114 | 115 | export const getConfig = (env: Environment['Bindings']) => { 116 | if (config) { 117 | return config 118 | } 119 | let envVars: EnvVarsSchemaType 120 | try { 121 | envVars = envVarsSchema.parse(env) 122 | } catch (err) { 123 | if (env.ENV && env.ENV === 'production') { 124 | throw new ApiError(httpStatus.INTERNAL_SERVER_ERROR, 'Invalid server configuration') 125 | } 126 | if (err instanceof ZodError) { 127 | const errorMessage = generateZodErrorMessage(err) 128 | throw new ApiError(httpStatus.INTERNAL_SERVER_ERROR, errorMessage) 129 | } 130 | throw err 131 | } 132 | config = { 133 | env: envVars.ENV, 134 | database: { 135 | name: envVars.DATABASE_NAME, 136 | username: envVars.DATABASE_USERNAME, 137 | password: envVars.DATABASE_PASSWORD, 138 | host: envVars.DATABASE_HOST 139 | }, 140 | jwt: { 141 | secret: envVars.JWT_SECRET, 142 | accessExpirationMinutes: envVars.JWT_ACCESS_EXPIRATION_MINUTES, 143 | refreshExpirationDays: envVars.JWT_REFRESH_EXPIRATION_DAYS, 144 | resetPasswordExpirationMinutes: envVars.JWT_RESET_PASSWORD_EXPIRATION_MINUTES, 145 | verifyEmailExpirationMinutes: envVars.JWT_VERIFY_EMAIL_EXPIRATION_MINUTES 146 | }, 147 | aws: { 148 | accessKeyId: envVars.AWS_ACCESS_KEY_ID, 149 | secretAccessKey: envVars.AWS_SECRET_ACCESS_KEY, 150 | region: envVars.AWS_REGION 151 | }, 152 | email: { 153 | sender: envVars.EMAIL_SENDER 154 | }, 155 | oauth: { 156 | platform: { 157 | web: { 158 | redirectUrl: envVars.OAUTH_WEB_REDIRECT_URL 159 | }, 160 | android: { 161 | redirectUrl: envVars.OAUTH_ANDROID_REDIRECT_URL 162 | }, 163 | ios: { 164 | redirectUrl: envVars.OAUTH_IOS_REDIRECT_URL 165 | } 166 | }, 167 | provider: { 168 | github: { 169 | clientId: envVars.OAUTH_GITHUB_CLIENT_ID, 170 | clientSecret: envVars.OAUTH_GITHUB_CLIENT_SECRET 171 | }, 172 | google: { 173 | clientId: envVars.OAUTH_GOOGLE_CLIENT_ID, 174 | clientSecret: envVars.OAUTH_GOOGLE_CLIENT_SECRET 175 | }, 176 | spotify: { 177 | clientId: envVars.OAUTH_SPOTIFY_CLIENT_ID, 178 | clientSecret: envVars.OAUTH_SPOTIFY_CLIENT_SECRET 179 | }, 180 | discord: { 181 | clientId: envVars.OAUTH_DISCORD_CLIENT_ID, 182 | clientSecret: envVars.OAUTH_DISCORD_CLIENT_SECRET 183 | }, 184 | facebook: { 185 | clientId: envVars.OAUTH_FACEBOOK_CLIENT_ID, 186 | clientSecret: envVars.OAUTH_FACEBOOK_CLIENT_SECRET 187 | }, 188 | apple: { 189 | clientId: envVars.OAUTH_APPLE_CLIENT_ID, 190 | privateKey: envVars.OAUTH_APPLE_PRIVATE_KEY, 191 | keyId: envVars.OAUTH_APPLE_KEY_ID, 192 | teamId: envVars.OAUTH_APPLE_TEAM_ID, 193 | jwtAccessExpirationMinutes: envVars.OAUTH_APPLE_JWT_ACCESS_EXPIRATION_MINUTES, 194 | redirectUrl: envVars.OAUTH_APPLE_REDIRECT_URL 195 | } 196 | } 197 | } 198 | } 199 | return config 200 | } 201 | -------------------------------------------------------------------------------- /src/services/auth.service.ts: -------------------------------------------------------------------------------- 1 | import httpStatus from 'http-status' 2 | import { Config } from '../config/config' 3 | import { getDBClient } from '../config/database' 4 | import { Role } from '../config/roles' 5 | import { tokenTypes } from '../config/tokens' 6 | import { OAuthUserModel } from '../models/oauth/oauth-base.model' 7 | import { TokenResponse } from '../models/token.model' 8 | import { User } from '../models/user.model' 9 | import { AuthProviderType } from '../types/oauth.types' 10 | import { ApiError } from '../utils/api-error' 11 | import { Register } from '../validations/auth.validation' 12 | import * as tokenService from './token.service' 13 | import * as userService from './user.service' 14 | import { createUser } from './user.service' 15 | 16 | export const loginUserWithEmailAndPassword = async ( 17 | email: string, 18 | password: string, 19 | databaseConfig: Config['database'] 20 | ): Promise => { 21 | const user = await userService.getUserByEmail(email, databaseConfig) 22 | // If password is null then the user must login with a social account 23 | if (user && !user.password) { 24 | throw new ApiError(httpStatus.UNAUTHORIZED, 'Please login with your social account') 25 | } 26 | if (!user || !(await user.isPasswordMatch(password))) { 27 | throw new ApiError(httpStatus.UNAUTHORIZED, 'Incorrect email or password') 28 | } 29 | return user 30 | } 31 | 32 | export const refreshAuth = async (refreshToken: string, config: Config): Promise => { 33 | try { 34 | const refreshTokenDoc = await tokenService.verifyToken( 35 | refreshToken, 36 | tokenTypes.REFRESH, 37 | config.jwt.secret 38 | ) 39 | if (!refreshTokenDoc.sub) { 40 | throw new Error() 41 | } 42 | const user = await userService.getUserById(refreshTokenDoc.sub, config.database) 43 | if (!user) { 44 | throw new Error() 45 | } 46 | return tokenService.generateAuthTokens(user, config.jwt) 47 | } catch { 48 | throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate') 49 | } 50 | } 51 | 52 | export const register = async ( 53 | body: Register, 54 | databaseConfig: Config['database'] 55 | ): Promise => { 56 | const registerBody = { ...body, role: 'user' as Role, is_email_verified: false } 57 | const newUser = await createUser(registerBody, databaseConfig) 58 | return newUser 59 | } 60 | 61 | export const resetPassword = async ( 62 | resetPasswordToken: string, 63 | newPassword: string, 64 | config: Config 65 | ): Promise => { 66 | try { 67 | const resetPasswordTokenDoc = await tokenService.verifyToken( 68 | resetPasswordToken, 69 | tokenTypes.RESET_PASSWORD, 70 | config.jwt.secret 71 | ) 72 | if (!resetPasswordTokenDoc.sub) { 73 | throw new Error() 74 | } 75 | const userId = resetPasswordTokenDoc.sub 76 | const user = await userService.getUserById(userId, config.database) 77 | if (!user) { 78 | throw new Error() 79 | } 80 | await userService.updateUserById(user.id, { password: newPassword }, config.database) 81 | } catch { 82 | throw new ApiError(httpStatus.UNAUTHORIZED, 'Password reset failed') 83 | } 84 | } 85 | 86 | export const verifyEmail = async (verifyEmailToken: string, config: Config): Promise => { 87 | try { 88 | const verifyEmailTokenDoc = await tokenService.verifyToken( 89 | verifyEmailToken, 90 | tokenTypes.VERIFY_EMAIL, 91 | config.jwt.secret 92 | ) 93 | if (!verifyEmailTokenDoc.sub) { 94 | throw new Error() 95 | } 96 | const userId = verifyEmailTokenDoc.sub 97 | const user = await userService.getUserById(userId, config.database) 98 | if (!user) { 99 | throw new Error() 100 | } 101 | await userService.updateUserById(user.id, { is_email_verified: true }, config.database) 102 | } catch { 103 | throw new ApiError(httpStatus.UNAUTHORIZED, 'Email verification failed') 104 | } 105 | } 106 | 107 | export const loginOrCreateUserWithOauth = async ( 108 | providerUser: OAuthUserModel, 109 | databaseConfig: Config['database'] 110 | ): Promise => { 111 | const user = await userService.getUserByProviderIdType( 112 | providerUser._id, 113 | providerUser.providerType, 114 | databaseConfig 115 | ) 116 | if (user) return user 117 | return userService.createOauthUser(providerUser, databaseConfig) 118 | } 119 | 120 | export const linkUserWithOauth = async ( 121 | userId: string, 122 | providerUser: OAuthUserModel, 123 | databaseConfig: Config['database'] 124 | ): Promise => { 125 | const db = getDBClient(databaseConfig) 126 | await db.transaction().execute(async (trx) => { 127 | try { 128 | await trx 129 | .selectFrom('user') 130 | .selectAll() 131 | .where('user.id', '=', userId) 132 | .executeTakeFirstOrThrow() 133 | } catch { 134 | throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate') 135 | } 136 | await trx 137 | .insertInto('authorisations') 138 | .values({ 139 | user_id: userId, 140 | provider_user_id: providerUser._id, 141 | provider_type: providerUser.providerType 142 | }) 143 | .executeTakeFirstOrThrow() 144 | }) 145 | } 146 | 147 | export const deleteOauthLink = async ( 148 | userId: string, 149 | provider: AuthProviderType, 150 | databaseConfig: Config['database'] 151 | ): Promise => { 152 | const db = getDBClient(databaseConfig) 153 | await db.transaction().execute(async (trx) => { 154 | const { count } = trx.fn 155 | let loginsNo: number 156 | try { 157 | const logins = await trx 158 | .selectFrom('user') 159 | .select('password') 160 | .select(count('authorisations.provider_user_id').as('authorisations')) 161 | .leftJoin('authorisations', 'authorisations.user_id', 'user.id') 162 | .where('user.id', '=', userId) 163 | .groupBy('user.password') 164 | .executeTakeFirstOrThrow() 165 | loginsNo = logins.password !== null ? logins.authorisations + 1 : logins.authorisations 166 | } catch { 167 | throw new ApiError(httpStatus.BAD_REQUEST, 'Account not linked') 168 | } 169 | const minLoginMethods = 1 170 | if (loginsNo <= minLoginMethods) { 171 | throw new ApiError(httpStatus.BAD_REQUEST, 'Cannot unlink last login method') 172 | } 173 | const result = await trx 174 | .deleteFrom('authorisations') 175 | .where('user_id', '=', userId) 176 | .where('provider_type', '=', provider) 177 | .executeTakeFirst() 178 | if (!result.numDeletedRows || Number(result.numDeletedRows) < 1) { 179 | throw new ApiError(httpStatus.BAD_REQUEST, 'Account not linked') 180 | } 181 | }) 182 | } 183 | -------------------------------------------------------------------------------- /src/services/user.service.ts: -------------------------------------------------------------------------------- 1 | import httpStatus from 'http-status' 2 | import { UpdateResult } from 'kysely' 3 | import { Config } from '../config/config' 4 | import { getDBClient } from '../config/database' 5 | import { OAuthUserModel } from '../models/oauth/oauth-base.model' 6 | import { User } from '../models/user.model' 7 | import { UserTable } from '../tables/user.table' 8 | import { AuthProviderType } from '../types/oauth.types' 9 | import { ApiError } from '../utils/api-error' 10 | import { generateId } from '../utils/utils' 11 | import { CreateUser, UpdateUser } from '../validations/user.validation' 12 | 13 | interface getUsersFilter { 14 | email: string | undefined 15 | } 16 | 17 | interface getUsersOptions { 18 | sortBy: string 19 | limit: number 20 | page: number 21 | } 22 | 23 | export const createUser = async ( 24 | userBody: CreateUser, 25 | databaseConfig: Config['database'] 26 | ): Promise => { 27 | const db = getDBClient(databaseConfig) 28 | const id = generateId() 29 | try { 30 | await db 31 | .insertInto('user') 32 | .values({ ...userBody, id }) 33 | .executeTakeFirstOrThrow() 34 | } catch { 35 | throw new ApiError(httpStatus.BAD_REQUEST, 'User already exists') 36 | } 37 | const user = (await getUserById(id, databaseConfig)) as User 38 | return user 39 | } 40 | 41 | export const createOauthUser = async ( 42 | providerUser: OAuthUserModel, 43 | databaseConfig: Config['database'] 44 | ): Promise => { 45 | const db = getDBClient(databaseConfig) 46 | try { 47 | const id = generateId() 48 | await db.transaction().execute(async (trx) => { 49 | await trx 50 | .insertInto('user') 51 | .values({ 52 | id, 53 | name: providerUser._name, 54 | email: providerUser._email, 55 | is_email_verified: true, 56 | password: null, 57 | role: 'user' 58 | }) 59 | .executeTakeFirstOrThrow() 60 | await trx 61 | .insertInto('authorisations') 62 | .values({ 63 | user_id: id, 64 | provider_type: providerUser.providerType, 65 | provider_user_id: providerUser._id 66 | }) 67 | .executeTakeFirstOrThrow() 68 | }) 69 | } catch { 70 | throw new ApiError( 71 | httpStatus.FORBIDDEN, 72 | `Cannot signup with ${providerUser.providerType}, user already exists with that email` 73 | ) 74 | } 75 | const user = (await getUserByProviderIdType( 76 | providerUser._id, 77 | providerUser.providerType, 78 | databaseConfig 79 | )) as User 80 | return new User(user) 81 | } 82 | 83 | export const queryUsers = async ( 84 | filter: getUsersFilter, 85 | options: getUsersOptions, 86 | databaseConfig: Config['database'] 87 | ): Promise => { 88 | const db = getDBClient(databaseConfig) 89 | const [sortField, direction] = options.sortBy.split(':') as [keyof UserTable, 'asc' | 'desc'] 90 | let usersQuery = db 91 | .selectFrom('user') 92 | .selectAll() 93 | .orderBy(`user.${sortField}`, direction) 94 | .limit(options.limit) 95 | .offset(options.limit * options.page) 96 | if (filter.email) { 97 | usersQuery = usersQuery.where('user.email', '=', filter.email) 98 | } 99 | const users = await usersQuery.execute() 100 | return users.map((user) => new User(user)) 101 | } 102 | 103 | export const getUserById = async ( 104 | id: string, 105 | databaseConfig: Config['database'] 106 | ): Promise => { 107 | const db = getDBClient(databaseConfig) 108 | const user = await db.selectFrom('user').selectAll().where('user.id', '=', id).executeTakeFirst() 109 | return user ? new User(user) : undefined 110 | } 111 | 112 | export const getUserByEmail = async ( 113 | email: string, 114 | databaseConfig: Config['database'] 115 | ): Promise => { 116 | const db = getDBClient(databaseConfig) 117 | const user = await db 118 | .selectFrom('user') 119 | .selectAll() 120 | .where('user.email', '=', email) 121 | .executeTakeFirst() 122 | return user ? new User(user) : undefined 123 | } 124 | 125 | export const getUserByProviderIdType = async ( 126 | id: string, 127 | type: AuthProviderType, 128 | databaseConfig: Config['database'] 129 | ): Promise => { 130 | const db = getDBClient(databaseConfig) 131 | const user = await db 132 | .selectFrom('user') 133 | .innerJoin('authorisations', 'authorisations.user_id', 'user.id') 134 | .selectAll() 135 | .where('authorisations.provider_user_id', '=', id) 136 | .where('authorisations.provider_type', '=', type) 137 | .executeTakeFirst() 138 | return user ? new User(user) : undefined 139 | } 140 | 141 | export const updateUserById = async ( 142 | userId: string, 143 | updateBody: Partial, 144 | databaseConfig: Config['database'] 145 | ): Promise => { 146 | const db = getDBClient(databaseConfig) 147 | let result: UpdateResult 148 | try { 149 | result = await db 150 | .updateTable('user') 151 | .set(updateBody) 152 | .where('id', '=', userId) 153 | .executeTakeFirst() 154 | } catch { 155 | throw new ApiError(httpStatus.BAD_REQUEST, 'User already exists') 156 | } 157 | if (!result.numUpdatedRows || Number(result.numUpdatedRows) < 1) { 158 | throw new ApiError(httpStatus.NOT_FOUND, 'User not found') 159 | } 160 | const user = (await getUserById(userId, databaseConfig)) as User 161 | return user 162 | } 163 | 164 | export const deleteUserById = async ( 165 | userId: string, 166 | databaseConfig: Config['database'] 167 | ): Promise => { 168 | const db = getDBClient(databaseConfig) 169 | const result = await db.deleteFrom('user').where('user.id', '=', userId).executeTakeFirst() 170 | 171 | if (!result.numDeletedRows || Number(result.numDeletedRows) < 1) { 172 | throw new ApiError(httpStatus.NOT_FOUND, 'User not found') 173 | } 174 | } 175 | 176 | export const getAuthorisations = async (userId: string, databaseConfig: Config['database']) => { 177 | const db = getDBClient(databaseConfig) 178 | const auths = await db 179 | .selectFrom('user') 180 | .leftJoin('authorisations', 'authorisations.user_id', 'user.id') 181 | .selectAll() 182 | .where('user.id', '=', userId) 183 | .execute() 184 | 185 | if (!auths) { 186 | throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate') 187 | } 188 | const response = { 189 | local: auths[0].password !== null ? true : false, 190 | google: false, 191 | facebook: false, 192 | discord: false, 193 | spotify: false, 194 | github: false, 195 | apple: false 196 | } 197 | for (const auth of auths) { 198 | if (auth.provider_type === null) { 199 | continue 200 | } 201 | response[auth.provider_type as AuthProviderType] = true 202 | } 203 | return response 204 | } 205 | -------------------------------------------------------------------------------- /tests/mocks/awsClientStub/aws-client-stub.ts: -------------------------------------------------------------------------------- 1 | import { Client, Command, MetadataBearer } from '@smithy/types' 2 | import { MockInstance, vi, Mock } from 'vitest' 3 | import { mockClient } from './mock-client' 4 | 5 | export type AwsClientBehavior = 6 | TClient extends Client 7 | ? Behavior 8 | : never 9 | 10 | export interface Behavior< 11 | TInput extends object, 12 | TOutput extends MetadataBearer, 13 | TCommandOutput extends TOutput, 14 | TConfiguration 15 | > { 16 | on( 17 | command: new (input: TCmdInput) => AwsCommand, 18 | input?: Partial, 19 | strict?: boolean 20 | ): Behavior 21 | 22 | resolves(response: CommandResponse): AwsStub 23 | 24 | rejects(error?: string | Error | AwsError): AwsStub 25 | } 26 | 27 | /** 28 | * Type for {@link AwsStub} class, 29 | * but with the AWS Client class type as an only generic parameter. 30 | * 31 | * @example 32 | * ```ts 33 | * let snsMock: AwsClientStub; 34 | * snsMock = mockClient(SNSClient); 35 | * ``` 36 | */ 37 | export type AwsClientStub = 38 | TClient extends Client 39 | ? AwsStub 40 | : never 41 | 42 | type MockCall = { 43 | args: In 44 | result: MockResult 45 | } 46 | 47 | type MockResult = 48 | | { 49 | type: 'return' 50 | value: T 51 | } 52 | | { 53 | type: 'throw' 54 | value: unknown 55 | } 56 | 57 | type Inputs = Parameters< 58 | Client['send'] 59 | > 60 | type Output = ReturnType< 61 | Client['send'] 62 | > 63 | 64 | /** 65 | * Wrapper on the mocked `Client#send()` method, 66 | * allowing to configure its behavior. 67 | * 68 | * Without any configuration, `Client#send()` invocation returns `undefined`. 69 | * 70 | * To define resulting variable type easily, use {@link AwsClientStub}. 71 | */ 72 | export class AwsStub { 73 | /** 74 | * Underlying `Client#send()` method Sinon stub. 75 | * 76 | * Install `@types/sinon` for TypeScript typings. 77 | */ 78 | public send: MockInstance< 79 | Inputs, 80 | Output 81 | > 82 | 83 | constructor( 84 | private client: Client, 85 | send: MockInstance< 86 | Inputs, 87 | Output 88 | > 89 | ) { 90 | this.send = send 91 | } 92 | 93 | /** Returns the class name of the underlying mocked client class */ 94 | clientName(): string { 95 | return this.client.constructor.name 96 | } 97 | 98 | /** 99 | * Resets stub. It will replace the stub with a new one, with clean history and behavior. 100 | */ 101 | reset(): AwsStub { 102 | /* sinon.stub.reset() does not remove the fakes which in some conditions can break subsequent stubs, 103 | * so instead of calling send.reset(), we recreate the stub. 104 | * See: https://github.com/sinonjs/sinon/issues/1572 105 | * We are only affected by the broken reset() behavior of this bug, since we always use matchers. 106 | */ 107 | const newStub = mockClient(this.client) 108 | this.send = newStub.send 109 | return this 110 | } 111 | 112 | /** Replaces stub with original `Client#send()` method. */ 113 | restore(): void { 114 | this.send.mockRestore() 115 | } 116 | 117 | /** 118 | * Returns recorded calls to the stub. 119 | */ 120 | calls(): MockCall< 121 | Inputs, 122 | Output 123 | >[] { 124 | return this.send.mock.calls.map( 125 | (call, i) => 126 | ({ 127 | args: call, 128 | result: this.send.mock.results[i] 129 | }) as MockCall< 130 | Inputs, 131 | Output 132 | > 133 | ) 134 | } 135 | 136 | /** 137 | * Returns n-th recorded call to the stub. 138 | */ 139 | call( 140 | n: number 141 | ): MockCall, Output> { 142 | return this.calls()[n] 143 | } 144 | 145 | /** 146 | * Allows specifying the behavior for a given Command type and its input (parameters). 147 | * 148 | * If the input is not specified, it will match any Command of that type. 149 | * 150 | * @example 151 | * ```js 152 | * snsMock 153 | * .on(PublishCommand, {Message: 'My message'}) 154 | * .resolves({MessageId: '111'}); 155 | * ``` 156 | * 157 | * @param command Command type to match 158 | * @param input Command payload to match 159 | * @param strict Should the payload match strictly (default false, will match if all defined payload properties match) 160 | */ 161 | on( 162 | command: new (input: TCmdInput) => AwsCommand 163 | ): CommandBehavior { 164 | const cmdStub: Mock< 165 | Inputs, 166 | Output 167 | > = vi.fn((cmd, opts, cb) => { 168 | return this.client.send(cmd, opts, cb) 169 | }) 170 | this.send.mockImplementation((cmd, opts, cb) => { 171 | if (cmd instanceof command) return cmdStub(cmd, opts, cb) 172 | return this.client.send(cmd, opts, cb) 173 | }) 174 | return new CommandBehavior(this, cmdStub) 175 | } 176 | } 177 | 178 | export class CommandBehavior< 179 | TInput extends object, 180 | TOutput extends MetadataBearer, 181 | TCommandOutput extends TOutput, 182 | TConfiguration 183 | > { 184 | constructor( 185 | private clientStub: AwsStub, 186 | private send: Mock< 187 | Inputs, 188 | Output 189 | > 190 | ) {} 191 | 192 | /** 193 | * Sets a successful response that will be returned from `Client#send()` invocation for the current `Command`. 194 | * 195 | * @example 196 | * ```js 197 | * snsMock 198 | * .on(PublishCommand) 199 | * .resolves({MessageId: '111'}); 200 | * ``` 201 | * 202 | * @param response Content to be returned 203 | */ 204 | resolves( 205 | response: Awaited> 206 | ): AwsStub { 207 | this.send.mockImplementation(() => Promise.resolve(response) as unknown as Promise) 208 | return this.clientStub 209 | } 210 | 211 | /** 212 | * Sets a failure response that will be returned from `Client#send()` invocation for the current `Command`. 213 | * The response will always be an `Error` instance. 214 | * 215 | * @example 216 | * ```js 217 | * snsMock 218 | * .on(PublishCommand) 219 | * .rejects('mocked rejection'); 220 | *``` 221 | * 222 | * @example 223 | * ```js 224 | * const throttlingError = new Error('mocked rejection'); 225 | * throttlingError.name = 'ThrottlingException'; 226 | * snsMock 227 | * .on(PublishCommand) 228 | * .rejects(throttlingError); 229 | * ``` 230 | * 231 | * @param error Error text, Error instance or Error parameters to be returned 232 | */ 233 | rejects(error?: string | Error | AwsError): AwsStub { 234 | this.send.mockImplementation(() => Promise.reject(error)) 235 | return this.clientStub 236 | } 237 | } 238 | 239 | export type AwsCommand< 240 | Input extends ClientInput, 241 | Output extends ClientOutput, 242 | ClientInput extends object, 243 | ClientOutput extends MetadataBearer 244 | > = Command 245 | type CommandResponse = Partial | PromiseLike> 246 | 247 | export interface AwsError extends Partial, Partial { 248 | Type?: string 249 | Code?: string 250 | $fault?: 'client' | 'server' 251 | $service?: string 252 | } 253 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RESTful API Cloudflare Workers Boilerplate 2 | A boilerplate/starter project for quickly building RESTful APIs using 3 | [Cloudflare Workers](https://workers.cloudflare.com/), [Hono](https://honojs.dev/), and 4 | [PlanetScale](https://planetscale.com/). Inspired by 5 | [node-express-boilerplate](https://github.com/hagopj13/node-express-boilerplate) by hagopj13. 6 | 7 | ## Quick Start 8 | 9 | To create a project, simply run: 10 | 11 | ```bash 12 | npx create-cf-planetscale-app 13 | ``` 14 | 15 | Or 16 | 17 | ```bash 18 | npm init cf-planetscale-app 19 | ``` 20 | 21 | ## Table of Contents 22 | 23 | - [RESTful API Cloudflare Workers Boilerplate](#restful-api-cloudflare-workers-boilerplate) 24 | - [Quick Start](#quick-start) 25 | - [Table of Contents](#table-of-contents) 26 | - [Features](#features) 27 | - [Commands](#commands) 28 | - [Error Handling](#error-handling) 29 | - [Validation](#validation) 30 | - [Authentication](#authentication) 31 | - [Emails](#emails) 32 | - [Authorisation](#authorisation) 33 | - [Rate Limiting](#rate-limiting) 34 | - [Contributing](#contributing) 35 | - [Inspirations](#inspirations) 36 | - [License](#license) 37 | 38 | ## Features 39 | 40 | - **SQL database**: [PlanetScale](https://planetscale.com/) using 41 | [Kysely](https://github.com/koskimas/kysely) as a type-safe SQl query builder 42 | - **Authentication and authorization**: using JWT 43 | - **Validation**: request data validation using [Zod](https://github.com/colinhacks/zod) 44 | - **Logging**: using [Sentry](https://sentry.io/) 45 | - **Testing**: unit and integration tests using [Vitest](https://vitest.dev/) 46 | - **Error handling**: centralised error handling mechanism provided by [Hono](https://honojs.dev/) 47 | - **Git hooks**: with [Husky](https://github.com/typicode/husky) 48 | - **Linting**: with [ESLint](https://eslint.org) and [Prettier](https://prettier.io) 49 | - **Emails**: with [Amazon SES](https://aws.amazon.com/ses/) 50 | - **Oauth**: Support for Discord, Github, Spotify, Google, Apple and Facebook. Support coming for 51 | Instagram and Twitter 52 | - **Rate Limiting**: using Cloudflare durable objects you can rate limit endpoints usin the sliding 53 | window algorithm 54 | 55 | ## Commands 56 | 57 | Running locally: 58 | 59 | ```bash 60 | npm run dev 61 | ``` 62 | 63 | Testing: 64 | 65 | ```bash 66 | # run all tests 67 | npm run tests 68 | 69 | # run test coverage 70 | npm run tests:coverage 71 | ``` 72 | 73 | Linting: 74 | 75 | ```bash 76 | # run ESLint 77 | npm run lint 78 | 79 | # fix ESLint errors 80 | npm run lint:fix 81 | 82 | # run prettier 83 | npm run prettier 84 | 85 | # fix prettier errors 86 | npm run prettier:fix 87 | ``` 88 | 89 | Migrations: 90 | 91 | To deploy to production you must first deploy to a test/dev branch on Planetscale and then create 92 | a deploy request and merge the schema into production. 93 | 94 | ```bash 95 | # run all migrations for testing 96 | npm run migrate:test:latest 97 | 98 | # remove all migrations for testing 99 | npm run migrate:test:none 100 | 101 | # revert last migration for testing 102 | npm run migrate:test:down 103 | ``` 104 | 105 | Deploy to Cloudflare: 106 | 107 | ```bash 108 | npm run deploy 109 | npm run deploy 110 | ``` 111 | 112 | ## Error Handling 113 | 114 | The app has a centralized error handling mechanism provided by [Hono](https://honojs.dev/). 115 | 116 | ```javascript 117 | app.onError(errorHandler) 118 | ``` 119 | 120 | All errors will be caught by the errorHandler which converts the error to an ApiError and formats 121 | it in a JSON response. Any errors that aren't intentionally thrown, e.g. 500 errors, are logged to 122 | Sentry. 123 | 124 | The error handling middleware sends an error response, which has the following format: 125 | 126 | ```json 127 | { 128 | "code": 404, 129 | "message": "Not found" 130 | } 131 | ``` 132 | 133 | When running in development mode, the error response also contains the error stack. 134 | 135 | ## Validation 136 | 137 | Request data is validated using [Zod](https://github.com/colinhacks/zod). 138 | 139 | The validation schemas are defined in the `src/validations` directory and are used in the 140 | controllers by getting either the query or body and then calling the parse on the relevant 141 | validation function: 142 | 143 | 144 | ```javascript 145 | const getUsers: Handler<{ Bindings: Bindings }> = async (c) => { 146 | const config = getConfig(c.env) 147 | const queryParse = c.req.query() 148 | const query = userValidation.getUsers.parse(queryParse) 149 | const filter = { email: query.email } 150 | const options = { sortBy: query.sort_by, limit: query.limit, page: query.page } 151 | const result = await userService.queryUsers(filter, options, config.database) 152 | return c.json(result, httpStatus.OK) 153 | } 154 | ``` 155 | 156 | ## Authentication 157 | 158 | To require authentication for certain routes, you can use the `auth` middleware. 159 | 160 | ```javascript 161 | import { Hono } from 'hono' 162 | import * as userController from '../controllers/user.controller' 163 | import { auth } from '../middlewares/auth' 164 | 165 | const route = new Hono<{ Bindings: Bindings }>() 166 | 167 | route.post('/', auth(), userController.createUser) 168 | 169 | export { route } 170 | 171 | ``` 172 | 173 | These routes require a valid JWT access token in the Authorization request header using the Bearer 174 | schema. If the request does not contain a valid access token, an Unauthorized (401) error is thrown. 175 | 176 | ## Emails 177 | 178 | Support for Email sending using [Amazon SES](https://aws.amazon.com/ses/). Just call the `sendEmail` 179 | function in `src/services/email.service.ts`: 180 | 181 | ```javascript 182 | const sendResetPasswordEmail = async (email: string, emailData: EmailData, config: Config) => { 183 | const message = { 184 | Subject: { 185 | Data: 'Reset your password', 186 | Charset: 'UTF-8' 187 | }, 188 | Body: { 189 | Text: { 190 | Charset: 'UTF-8', 191 | Data: ` 192 | Hello ${emailData.name} 193 | Please reset your password by clicking the following link: 194 | ${emailData.token} 195 | ` 196 | } 197 | } 198 | } 199 | await sendEmail(email, config.email.sender, message, config.aws) 200 | } 201 | ``` 202 | 203 | ## Authorisation 204 | 205 | The `auth` middleware can also be used to require certain rights/permissions to access a route. 206 | 207 | ```javascript 208 | import { Hono } from 'hono' 209 | import * as userController from '../controllers/user.controller' 210 | import { auth } from '../middlewares/auth' 211 | 212 | const route = new Hono<{ Bindings: Bindings }>() 213 | 214 | route.post('/', auth('manageUsers'), userController.createUser) 215 | 216 | export { route } 217 | ``` 218 | 219 | In the example above, an authenticated user can access this route only if that user has the 220 | `manageUsers` permission. 221 | 222 | The permissions are role-based. You can view the permissions/rights of each role in the 223 | `src/config/roles.ts` file. 224 | 225 | If the user making the request does not have the required permissions to access this route, a 226 | Forbidden (403) error is thrown. 227 | 228 | ## Rate Limiting 229 | 230 | To apply rate limits for certain routes, you can use the `rateLimit` middleware. 231 | 232 | ```javascript 233 | import { Hono } from 'hono' 234 | import { Environment } from '../../bindings' 235 | import { auth } from '../middlewares/auth' 236 | import { rateLimit } from '../middlewares/rateLimiter' 237 | 238 | export const route = new Hono() 239 | 240 | const twoMinutes = 120 241 | const oneRequest = 1 242 | 243 | route.post( 244 | '/send-verification-email', 245 | auth(), 246 | rateLimit(twoMinutes, oneRequest), 247 | authController.sendVerificationEmail 248 | ) 249 | ``` 250 | 251 | This uses Cloudflare durable objects to apply rate limits using the sliding window algorithm. You 252 | can specify the interval size in seconds and how many requests are allowed per interval. 253 | 254 | If the rate limit is hit a `429` will be returned to the client. 255 | 256 | These headers are returned with each endpoint that has rate limiting applied: 257 | 258 | * `X-RateLimit-Limit` - How many requests are allowed per window 259 | * `X-RateLimit-Reset` - How many seconds until the current window resets 260 | * `X-RateLimit-Policy` - Details about the rate limit policy in this format `${limit};w=${interval};comment="Sliding window"` 261 | * `X-RateLimit-Remaining` - How many requests you can send until you will be rate limited. Please 262 | note this doesn't just reset to the limit when the reset period hits. Use it as indicator of your 263 | current throughput e.g. if you have 12 requests allowed every 1 second and remaining is 0 264 | you are at 100% throughput, but if it is 6 you are 50% throughput. This value constantly changes 265 | as the window progresses either increasing or decreasing based on your throughput 266 | 267 | The rate limit will be based on IP unless the user is authenticated then it will be based on the 268 | user ID. 269 | 270 | ## Contributing 271 | 272 | Contributions are more than welcome! 273 | 274 | ## Inspirations 275 | 276 | - [hagopj13/node-express-boilerplate](https://github.com/hagopj13/node-express-boilerplate) 277 | 278 | ## License 279 | 280 | [MIT](LICENSE) 281 | -------------------------------------------------------------------------------- /tests/integration/rate-limiter.test.ts: -------------------------------------------------------------------------------- 1 | import { env, runInDurableObject, runDurableObjectAlarm } from 'cloudflare:test' 2 | import dayjs from 'dayjs' 3 | import isSameOrBefore from 'dayjs/plugin/isSameOrBefore' 4 | import httpStatus from 'http-status' 5 | import MockDate from 'mockdate' 6 | import { test, describe, expect, beforeEach } from 'vitest' 7 | import { RateLimiter } from '../../src' 8 | 9 | dayjs.extend(isSameOrBefore) 10 | 11 | const key = '127.0.0.1' 12 | const id = env.RATE_LIMITER.idFromName(key) 13 | const fakeDomain = 'http://iamaratelimiter.com/' 14 | 15 | describe('Durable Object RateLimiter', () => { 16 | describe('Fetch /', () => { 17 | beforeEach(async () => { 18 | const stub = env.RATE_LIMITER.get(id) 19 | await runInDurableObject(stub, async (_, state) => { 20 | await state.storage.deleteAll() 21 | }) 22 | MockDate.reset() 23 | }) 24 | test('should return 200 and not rate limit if limit not hit', async () => { 25 | const config = { 26 | scope: '/v1/auth/send-verification-email', 27 | key, 28 | limit: 1, 29 | interval: 60 30 | } 31 | const rateLimiter = env.RATE_LIMITER.get(id) 32 | const res = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => { 33 | const res = new Request(fakeDomain, { 34 | method: 'POST', 35 | body: JSON.stringify(config) 36 | }) 37 | return await instance.fetch(res) 38 | }) 39 | const body = await res.json() 40 | expect(res.status).toBe(httpStatus.OK) 41 | expect(body).toEqual({ blocked: false, remaining: 0, expires: expect.any(String) }) 42 | }) 43 | 44 | test('should return 200 and rate limit if limit hit', async () => { 45 | const config = { 46 | scope: '/v1/auth/send-verification-email', 47 | key, 48 | limit: 200, 49 | interval: 600 50 | } 51 | const currentWindow = Math.floor(dayjs().unix() / config.interval) 52 | const storageKey = 53 | `${config.scope}|${config.key.toString()}|${config.limit}|` + 54 | `${config.interval}|${currentWindow}` 55 | const rateLimiter = env.RATE_LIMITER.get(id) 56 | await runInDurableObject(rateLimiter, async (_, state) => { 57 | await state.storage.put(storageKey, config.limit + 1) 58 | }) 59 | const start = dayjs() 60 | const res = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => { 61 | const res = new Request(fakeDomain, { 62 | method: 'POST', 63 | body: JSON.stringify(config) 64 | }) 65 | return await instance.fetch(res) 66 | }) 67 | const body = await res.json() 68 | expect(res.status).toBe(httpStatus.OK) 69 | expect(body).toEqual({ blocked: true, remaining: 0, expires: expect.any(String) }) 70 | 71 | const expires = dayjs(res.headers.get('expires')) 72 | expect(start.isSameOrBefore(expires)).toBe(true) 73 | 74 | const cacheControl = res.headers.get('cache-control') 75 | expect(cacheControl).toBeDefined() 76 | }) 77 | 78 | test('should return 200 and not rate limit if different endpoint hit', async () => { 79 | const config = { 80 | scope: '/v1/auth/send-verification-email', 81 | key, 82 | limit: 200, 83 | interval: 600 84 | } 85 | const currentWindow = Math.floor(dayjs().unix() / config.interval) 86 | const storageKey = 87 | `${config.scope}|${config.key.toString()}|${config.limit}|` + 88 | `${config.interval}|${currentWindow}` 89 | const rateLimiter = env.RATE_LIMITER.get(id) 90 | await runInDurableObject(rateLimiter, async (_, state) => { 91 | await state.storage.put(storageKey, config.limit + 1) 92 | }) 93 | const res = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => { 94 | const res = new Request(fakeDomain, { 95 | method: 'POST', 96 | body: JSON.stringify(config) 97 | }) 98 | return await instance.fetch(res) 99 | }) 100 | const body = await res.json() 101 | expect(res.status).toBe(httpStatus.OK) 102 | expect(body).toEqual({ blocked: true, remaining: 0, expires: expect.any(String) }) 103 | 104 | config.scope = '/v1/different-endpoint' 105 | const res2 = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => { 106 | const res = new Request(fakeDomain, { 107 | method: 'POST', 108 | body: JSON.stringify(config) 109 | }) 110 | return await instance.fetch(res) 111 | }) 112 | const body2 = await res2.json() 113 | expect(res2.status).toBe(httpStatus.OK) 114 | expect(body2).toEqual({ blocked: false, remaining: 199, expires: expect.any(String) }) 115 | }) 116 | 117 | test('should return 200 and not rate limit if different key used', async () => { 118 | const config = { 119 | scope: '/v1/auth/send-verification-email', 120 | key, 121 | limit: 200, 122 | interval: 600 123 | } 124 | const currentWindow = Math.floor(dayjs().unix() / config.interval) 125 | const storageKey = 126 | `${config.scope}|${config.key.toString()}|${config.limit}|` + 127 | `${config.interval}|${currentWindow}` 128 | const rateLimiter = env.RATE_LIMITER.get(id) 129 | await runInDurableObject(rateLimiter, async (_, state) => { 130 | await state.storage.put(storageKey, config.limit + 1) 131 | }) 132 | const res = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => { 133 | const res = new Request(fakeDomain, { 134 | method: 'POST', 135 | body: JSON.stringify(config) 136 | }) 137 | return await instance.fetch(res) 138 | }) 139 | const body = await res.json() 140 | expect(res.status).toBe(httpStatus.OK) 141 | expect(body).toEqual({ blocked: true, remaining: 0, expires: expect.any(String) }) 142 | config.key = '192.169.2.1' 143 | const res2 = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => { 144 | const res = new Request(fakeDomain, { 145 | method: 'POST', 146 | body: JSON.stringify(config) 147 | }) 148 | return await instance.fetch(res) 149 | }) 150 | const body2 = await res2.json() 151 | expect(res2.status).toBe(httpStatus.OK) 152 | expect(body2).toEqual({ blocked: false, remaining: 199, expires: expect.any(String) }) 153 | }) 154 | 155 | test('should return 200 and not rate limit if window expired', async () => { 156 | const config = { 157 | scope: '/v1/auth/send-verification-email', 158 | key, 159 | limit: 200, 160 | interval: 600 161 | } 162 | const currentWindow = Math.floor(dayjs().unix() / config.interval) 163 | const storageKey = 164 | `${config.scope}|${config.key.toString()}|${config.limit}|` + 165 | `${config.interval}|${currentWindow}` 166 | const rateLimiter = env.RATE_LIMITER.get(id) 167 | await runInDurableObject(rateLimiter, async (_, state) => { 168 | await state.storage.put(storageKey, config.limit) 169 | }) 170 | const res = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => { 171 | const res = new Request(fakeDomain, { 172 | method: 'POST', 173 | body: JSON.stringify(config) 174 | }) 175 | return await instance.fetch(res) 176 | }) 177 | const body = await res.json() 178 | expect(res.status).toBe(httpStatus.OK) 179 | expect(body).toEqual({ blocked: true, remaining: 0, expires: expect.any(String) }) 180 | const expires = dayjs(res.headers.get('expires')) 181 | MockDate.set(expires.add(1, 'second').toDate()) 182 | const res2 = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => { 183 | const res = new Request(fakeDomain, { 184 | method: 'POST', 185 | body: JSON.stringify(config) 186 | }) 187 | return await instance.fetch(res) 188 | }) 189 | const body2 = await res2.json() 190 | expect(res2.status).toBe(httpStatus.OK) 191 | expect(body2).toEqual({ blocked: false, remaining: 0, expires: expect.any(String) }) 192 | }) 193 | 194 | test('should return 200 and rate limit if just before window expiry', async () => { 195 | const config = { 196 | scope: '/v1/auth/send-verification-email', 197 | key, 198 | limit: 200, 199 | interval: 600 200 | } 201 | const currentWindow = Math.floor(dayjs().unix() / config.interval) 202 | const storageKey = 203 | `${config.scope}|${config.key.toString()}|${config.limit}|` + 204 | `${config.interval}|${currentWindow}` 205 | const rateLimiter = env.RATE_LIMITER.get(id) 206 | await runInDurableObject(rateLimiter, async (_, state) => { 207 | await state.storage.put(storageKey, config.limit + 1) 208 | }) 209 | const res = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => { 210 | const res = new Request(fakeDomain, { 211 | method: 'POST', 212 | body: JSON.stringify(config) 213 | }) 214 | return await instance.fetch(res) 215 | }) 216 | const body = await res.json() 217 | expect(res.status).toBe(httpStatus.OK) 218 | expect(body).toEqual({ blocked: true, remaining: 0, expires: expect.any(String) }) 219 | 220 | const expires = dayjs(res.headers.get('expires')).subtract(1, 'second') 221 | MockDate.set(expires.toDate()) 222 | 223 | const res2 = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => { 224 | const res = new Request(fakeDomain, { 225 | method: 'POST', 226 | body: JSON.stringify(config) 227 | }) 228 | return await instance.fetch(res) 229 | }) 230 | const body2 = await res2.json() 231 | expect(res2.status).toBe(httpStatus.OK) 232 | expect(body2).toEqual({ blocked: true, remaining: 0, expires: expect.any(String) }) 233 | }) 234 | 235 | test('should return 400 if config is invalid', async () => { 236 | const config = { 237 | key, 238 | limit: 1, 239 | interval: 60 240 | } 241 | expect(true).toBe(true) 242 | const rateLimiter = env.RATE_LIMITER.get(id) 243 | const res = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => { 244 | const res = new Request(fakeDomain, { 245 | method: 'POST', 246 | body: JSON.stringify(config) 247 | }) 248 | return await instance.fetch(res) 249 | }) 250 | const _ = await res.json() // https://github.com/cloudflare/workers-sdk/issues/5629 251 | expect(res.status).toBe(httpStatus.BAD_REQUEST) 252 | }) 253 | 254 | test('should return 400 if limit is not an integer', async () => { 255 | const config = { 256 | scope: '/v1/auth/send-verification-email', 257 | key, 258 | limit: 'hi', 259 | interval: 60 260 | } 261 | const rateLimiter = env.RATE_LIMITER.get(id) 262 | const res = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => { 263 | const res = new Request(fakeDomain, { 264 | method: 'POST', 265 | body: JSON.stringify(config) 266 | }) 267 | return await instance.fetch(res) 268 | }) 269 | const _ = await res.json() // https://github.com/cloudflare/workers-sdk/issues/5629 270 | expect(res.status).toBe(httpStatus.BAD_REQUEST) 271 | }) 272 | 273 | test('should return 400 if interval is not an integer', async () => { 274 | const config = { 275 | scope: '/v1/auth/send-verification-email', 276 | key, 277 | limit: 1, 278 | interval: 'hiiam interval' 279 | } 280 | const rateLimiter = env.RATE_LIMITER.get(id) 281 | const res = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => { 282 | const res = new Request(fakeDomain, { 283 | method: 'POST', 284 | body: JSON.stringify(config) 285 | }) 286 | return await instance.fetch(res) 287 | }) 288 | const _ = await res.json() // https://github.com/cloudflare/workers-sdk/issues/5629 289 | expect(res.status).toBe(httpStatus.BAD_REQUEST) 290 | }) 291 | }) 292 | 293 | describe('Alarm', () => { 294 | beforeEach(async () => { 295 | MockDate.reset() 296 | }) 297 | 298 | test('should expire key after 2 intervals have passed', async () => { 299 | const doConfig = { 300 | scope: '/v1/auth/send-verification-email', 301 | key, 302 | limit: 1, 303 | interval: 60 304 | } 305 | const rateLimiter = env.RATE_LIMITER.get(id) 306 | const currentWindow = Math.floor(dayjs().unix() / doConfig.interval) 307 | const storageKey = 308 | `${doConfig.scope}|${doConfig.key.toString()}|${doConfig.limit}|` + 309 | `${doConfig.interval}|${currentWindow}` 310 | 311 | const res = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => { 312 | const res = new Request(fakeDomain, { 313 | method: 'POST', 314 | body: JSON.stringify(doConfig) 315 | }) 316 | return await instance.fetch(res) 317 | }) 318 | const _ = await res.json() // https://github.com/cloudflare/workers-sdk/issues/5629 319 | expect(res.status).toBe(httpStatus.OK) 320 | const values = await runInDurableObject(rateLimiter, async (_, state) => { 321 | return await state.storage.list() 322 | }) 323 | expect(values.size).toBe(1) 324 | expect(values.get(storageKey)).toBe(1) 325 | 326 | MockDate.set( 327 | dayjs() 328 | .add(doConfig.interval * 3, 'seconds') 329 | .toDate() 330 | ) 331 | await runInDurableObject(rateLimiter, async (_, state) => { 332 | await state.storage.put(storageKey, doConfig.limit + 1) 333 | }) 334 | await runDurableObjectAlarm(rateLimiter) 335 | const values2 = await runInDurableObject(rateLimiter, async (_, state) => { 336 | return await state.storage.list() 337 | }) 338 | expect(values2.size).toBe(0) 339 | }) 340 | 341 | test('should not expire key if within 2 intervals', async () => { 342 | const config = { 343 | scope: '/v1/auth/send-verification-email', 344 | key, 345 | limit: 1, 346 | interval: 60 347 | } 348 | const rateLimiter = env.RATE_LIMITER.get(id) 349 | const currentWindow = Math.floor(dayjs().unix() / config.interval) 350 | const storageKey = 351 | `${config.scope}|${config.key.toString()}|${config.limit}|` + 352 | `${config.interval}|${currentWindow}` 353 | 354 | const res = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => { 355 | const res = new Request(fakeDomain, { 356 | method: 'POST', 357 | body: JSON.stringify(config) 358 | }) 359 | return await instance.fetch(res) 360 | }) 361 | const _ = await res.json() // https://github.com/cloudflare/workers-sdk/issues/5629 362 | expect(res.status).toBe(httpStatus.OK) 363 | const values = await runInDurableObject(rateLimiter, async (_, state) => { 364 | return await state.storage.list() 365 | }) 366 | expect(values.size).toBe(1) 367 | expect(values.get(storageKey)).toBe(1) 368 | 369 | MockDate.set( 370 | dayjs() 371 | .add(config.interval * 1.5, 'seconds') 372 | .toDate() 373 | ) 374 | await runDurableObjectAlarm(rateLimiter) 375 | const values2 = await runInDurableObject(rateLimiter, async (_, state) => { 376 | return await state.storage.list() 377 | }) 378 | expect(values2.size).toBe(1) 379 | expect(values2.get(storageKey)).toBe(1) 380 | }) 381 | 382 | test('should expire keys that are more than 2 intervals old and keep the others', async () => { 383 | const config = { 384 | scope: '/v1/auth/send-verification-email', 385 | key, 386 | limit: 1, 387 | interval: 60 388 | } 389 | const rateLimiter = env.RATE_LIMITER.get(id) 390 | 391 | const currentWindow = Math.floor(dayjs().unix() / config.interval) 392 | const storageKey = 393 | `${config.scope}|${config.key.toString()}|${config.limit}|` + 394 | `${config.interval}|${currentWindow}` 395 | 396 | const res = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => { 397 | const res = new Request(fakeDomain, { 398 | method: 'POST', 399 | body: JSON.stringify(config) 400 | }) 401 | return await instance.fetch(res) 402 | }) 403 | const _ = await res.json() // https://github.com/cloudflare/workers-sdk/issues/5629 404 | expect(res.status).toBe(httpStatus.OK) 405 | 406 | const expiredWindow = Math.floor(dayjs().unix() / config.interval - 3) 407 | const expiredStorageKey = 408 | `${config.scope}|${config.key.toString()}|${config.limit}|` + 409 | `${config.interval}|${expiredWindow}` 410 | 411 | await runInDurableObject(rateLimiter, async (_, state) => { 412 | await state.storage.put(expiredStorageKey, 45) 413 | }) 414 | 415 | const expiredWindow2 = Math.floor(dayjs().unix() / config.interval - 7) 416 | const expiredStorageKey2 = 417 | `${config.scope}|${config.key.toString()}|${config.limit}|` + 418 | `${config.interval}|${expiredWindow2}` 419 | 420 | await runInDurableObject(rateLimiter, async (_, state) => { 421 | await state.storage.put(expiredStorageKey2, 33) 422 | }) 423 | 424 | const expiredWindow3 = Math.floor(dayjs().unix() / config.interval - 4) 425 | const expiredStorageKey3 = 426 | `${config.scope}|${config.key.toString()}|${config.limit}|` + 427 | `${config.interval}|${expiredWindow3}` 428 | 429 | await runInDurableObject(rateLimiter, async (_, state) => { 430 | await state.storage.put(expiredStorageKey3, 12) 431 | }) 432 | 433 | const window2 = Math.floor(dayjs().unix() / config.interval - 1.5) 434 | const storageKey2 = 435 | `${config.scope}|${config.key.toString()}|${config.limit}|` + 436 | `${config.interval}|${window2}` 437 | 438 | await runInDurableObject(rateLimiter, async (_, state) => { 439 | await state.storage.put(storageKey2, 12) 440 | }) 441 | 442 | const values = await runInDurableObject(rateLimiter, async (_, state) => { 443 | return await state.storage.list() 444 | }) 445 | expect(values.size).toBe(5) 446 | 447 | await runDurableObjectAlarm(rateLimiter) 448 | 449 | const values2 = await runInDurableObject(rateLimiter, async (_, state) => { 450 | return await state.storage.list() 451 | }) 452 | expect(values2.size).toBe(2) 453 | expect(values2.get(expiredStorageKey)).toBeUndefined() 454 | expect(values2.get(expiredStorageKey2)).toBeUndefined() 455 | expect(values2.get(expiredStorageKey3)).toBeUndefined() 456 | expect(values2.get(storageKey)).toBe(1) 457 | expect(values2.get(storageKey2)).toBe(12) 458 | }) 459 | }) 460 | }) 461 | -------------------------------------------------------------------------------- /tests/integration/auth/oauth/github.test.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker' 2 | import { env, fetchMock } from 'cloudflare:test' 3 | import httpStatus from 'http-status' 4 | import { describe, expect, test, beforeAll, afterEach } from 'vitest' 5 | import { authProviders } from '../../../../src/config/authProviders' 6 | import { getConfig } from '../../../../src/config/config' 7 | import { getDBClient } from '../../../../src/config/database' 8 | import { tokenTypes } from '../../../../src/config/tokens' 9 | import { GithubUserType } from '../../../../src/types/oauth.types' 10 | import { 11 | appleAuthorisation, 12 | githubAuthorisation, 13 | googleAuthorisation, 14 | insertAuthorisations 15 | } from '../../../fixtures/authorisations.fixture' 16 | import { getAccessToken, TokenResponse } from '../../../fixtures/token.fixture' 17 | import { userOne, insertUsers, UserResponse, userTwo } from '../../../fixtures/user.fixture' 18 | import { clearDBTables } from '../../../utils/clear-db-tables' 19 | import { request } from '../../../utils/test-request' 20 | 21 | const config = getConfig(env) 22 | const client = getDBClient(config.database) 23 | 24 | clearDBTables(['user', 'authorisations'], config.database) 25 | 26 | describe('Oauth routes', () => { 27 | describe('GET /v1/auth/github/redirect', () => { 28 | test('should return 302 and successfully redirect to github', async () => { 29 | const state = btoa(JSON.stringify({ platform: 'web' })) 30 | const urlEncodedRedirectUrl = encodeURIComponent(config.oauth.platform.web.redirectUrl) 31 | const res = await request(`/v1/auth/github/redirect?state=${state}`, { 32 | method: 'GET' 33 | }) 34 | expect(res.status).toBe(httpStatus.FOUND) 35 | expect(res.headers.get('location')).toBe( 36 | 'https://github.com/login/oauth/authorize?allow_signup=true&' + 37 | `client_id=${config.oauth.provider.github.clientId}&` + 38 | `redirect_uri=${urlEncodedRedirectUrl}&scope=read%3Auser%20user%3Aemail&state=${state}` 39 | ) 40 | }) 41 | test('should return 400 error if state is not provided', async () => { 42 | const res = await request('/v1/auth/github/redirect', { method: 'GET' }) 43 | expect(res.status).toBe(httpStatus.BAD_REQUEST) 44 | }) 45 | test('should return 400 error if state platform is not provided', async () => { 46 | const state = btoa(JSON.stringify({})) 47 | const res = await request(`/v1/auth/github/redirect?state=${state}`, { 48 | method: 'GET' 49 | }) 50 | expect(res.status).toBe(httpStatus.BAD_REQUEST) 51 | }) 52 | test('should return 400 error if state platform is invalid', async () => { 53 | const state = btoa(JSON.stringify({ platform: 'fake' })) 54 | const res = await request(`/v1/auth/github/redirect?state=${state}`, { 55 | method: 'GET' 56 | }) 57 | expect(res.status).toBe(httpStatus.BAD_REQUEST) 58 | }) 59 | }) 60 | 61 | describe('POST /v1/auth/github/:userId', () => { 62 | let newUser: GithubUserType 63 | beforeAll(async () => { 64 | newUser = { 65 | id: faker.number.int(), 66 | name: faker.person.fullName(), 67 | email: faker.internet.email() 68 | } 69 | fetchMock.activate() 70 | }) 71 | afterEach(() => fetchMock.assertNoPendingInterceptors()) 72 | test('should return 200 and successfully link github account', async () => { 73 | await insertUsers([userOne], config.database) 74 | const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) 75 | 76 | const githubApiMock = fetchMock.get('https://api.github.com') 77 | githubApiMock.intercept({ method: 'GET', path: '/user' }).reply(200, JSON.stringify(newUser)) 78 | const githubMock = fetchMock.get('https://github.com') 79 | githubMock 80 | .intercept({ method: 'POST', path: '/login/oauth/access_token' }) 81 | .reply(200, JSON.stringify({ access_token: '1234' })) 82 | 83 | const providerId = '123456' 84 | const res = await request(`/v1/auth/github/${userOne.id}`, { 85 | method: 'POST', 86 | body: JSON.stringify({ code: providerId, platform: 'web' }), 87 | headers: { 88 | 'Content-Type': 'application/json', 89 | Authorization: `Bearer ${userOneAccessToken}` 90 | } 91 | }) 92 | expect(res.status).toBe(httpStatus.NO_CONTENT) 93 | 94 | const dbUser = await client 95 | .selectFrom('user') 96 | .selectAll() 97 | .where('user.id', '=', userOne.id) 98 | .executeTakeFirst() 99 | 100 | expect(dbUser).toBeDefined() 101 | if (!dbUser) return 102 | 103 | expect(dbUser.password).toBeDefined() 104 | expect(dbUser).toMatchObject({ 105 | name: userOne.name, 106 | password: expect.anything(), 107 | email: userOne.email, 108 | role: userOne.role, 109 | is_email_verified: 0 110 | }) 111 | 112 | const oauthUser = await client 113 | .selectFrom('authorisations') 114 | .selectAll() 115 | .where('authorisations.provider_type', '=', authProviders.GITHUB) 116 | .where('authorisations.user_id', '=', userOne.id) 117 | .where('authorisations.provider_user_id', '=', String(newUser.id)) 118 | .executeTakeFirst() 119 | 120 | expect(oauthUser).toBeDefined() 121 | if (!oauthUser) return 122 | }) 123 | 124 | test('should return 401 if user does not exist when linking', async () => { 125 | await insertUsers([userOne], config.database) 126 | const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) 127 | await client.deleteFrom('user').where('user.id', '=', userOne.id).execute() 128 | const githubApiMock = fetchMock.get('https://api.github.com') 129 | githubApiMock.intercept({ method: 'GET', path: '/user' }).reply(200, JSON.stringify(newUser)) 130 | const githubMock = fetchMock.get('https://github.com') 131 | githubMock 132 | .intercept({ method: 'POST', path: '/login/oauth/access_token' }) 133 | .reply(200, JSON.stringify({ access_token: '1234' })) 134 | 135 | const providerId = '123456' 136 | const res = await request(`/v1/auth/github/${userOne.id}`, { 137 | method: 'POST', 138 | body: JSON.stringify({ code: providerId, platform: 'web' }), 139 | headers: { 140 | 'Content-Type': 'application/json', 141 | Authorization: `Bearer ${userOneAccessToken}` 142 | } 143 | }) 144 | expect(res.status).toBe(httpStatus.UNAUTHORIZED) 145 | 146 | const oauthUser = await client 147 | .selectFrom('authorisations') 148 | .selectAll() 149 | .where('authorisations.provider_type', '=', authProviders.GITHUB) 150 | .where('authorisations.user_id', '=', userOne.id) 151 | .where('authorisations.provider_user_id', '=', String(newUser.id)) 152 | .executeTakeFirst() 153 | 154 | expect(oauthUser).toBeUndefined() 155 | }) 156 | 157 | test('should return 401 if code is invalid', async () => { 158 | await insertUsers([userOne], config.database) 159 | const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) 160 | const githubMock = fetchMock.get('https://github.com') 161 | githubMock 162 | .intercept({ method: 'POST', path: '/login/oauth/access_token' }) 163 | .reply(httpStatus.UNAUTHORIZED, JSON.stringify({ error: 'error' })) 164 | 165 | const providerId = '123456' 166 | const res = await request(`/v1/auth/github/${userOne.id}`, { 167 | method: 'POST', 168 | body: JSON.stringify({ code: providerId, platform: 'web' }), 169 | headers: { 170 | 'Content-Type': 'application/json', 171 | Authorization: `Bearer ${userOneAccessToken}` 172 | } 173 | }) 174 | expect(res.status).toBe(httpStatus.UNAUTHORIZED) 175 | }) 176 | 177 | test('should return 403 if linking different user', async () => { 178 | await insertUsers([userOne], config.database) 179 | const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) 180 | 181 | const providerId = '123456' 182 | const res = await request('/v1/auth/github/5298', { 183 | method: 'POST', 184 | body: JSON.stringify({ code: providerId, platform: 'web' }), 185 | headers: { 186 | 'Content-Type': 'application/json', 187 | Authorization: `Bearer ${userOneAccessToken}` 188 | } 189 | }) 190 | expect(res.status).toBe(httpStatus.FORBIDDEN) 191 | }) 192 | 193 | test('should return 400 if no code provided', async () => { 194 | await insertUsers([userOne], config.database) 195 | const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) 196 | 197 | const res = await request(`/v1/auth/github/${userOne.id}`, { 198 | method: 'POST', 199 | body: JSON.stringify({ platform: 'web' }), 200 | headers: { 201 | 'Content-Type': 'application/json', 202 | Authorization: `Bearer ${userOneAccessToken}` 203 | } 204 | }) 205 | expect(res.status).toBe(httpStatus.BAD_REQUEST) 206 | }) 207 | 208 | test('should return 401 error if access token is missing', async () => { 209 | const res = await request('/v1/auth/github/1234', { 210 | method: 'POST', 211 | body: JSON.stringify({}), 212 | headers: { 213 | 'Content-Type': 'application/json' 214 | } 215 | }) 216 | expect(res.status).toBe(httpStatus.UNAUTHORIZED) 217 | }) 218 | test('should return 403 if user has not verified their email', async () => { 219 | await insertUsers([userTwo], config.database) 220 | const accessToken = await getAccessToken( 221 | userTwo.id, 222 | userTwo.role, 223 | config.jwt, 224 | tokenTypes.ACCESS, 225 | userTwo.is_email_verified 226 | ) 227 | const res = await request('/v1/auth/github/5298', { 228 | method: 'POST', 229 | body: JSON.stringify({}), 230 | headers: { 231 | 'Content-Type': 'application/json', 232 | Authorization: `Bearer ${accessToken}` 233 | } 234 | }) 235 | expect(res.status).toBe(httpStatus.FORBIDDEN) 236 | }) 237 | test('should return 400 error if platform is not provided', async () => { 238 | await insertUsers([userOne], config.database) 239 | const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) 240 | const providerId = '123456' 241 | const res = await request(`/v1/auth/github/${userOne.id}`, { 242 | method: 'POST', 243 | body: JSON.stringify({ code: providerId }), 244 | headers: { 245 | 'Content-Type': 'application/json', 246 | Authorization: `Bearer ${userOneAccessToken}` 247 | } 248 | }) 249 | expect(res.status).toBe(httpStatus.BAD_REQUEST) 250 | }) 251 | test('should return 400 error if platform is invalid', async () => { 252 | await insertUsers([userOne], config.database) 253 | const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) 254 | const providerId = '123456' 255 | const res = await request(`/v1/auth/github/${userOne.id}`, { 256 | method: 'POST', 257 | body: JSON.stringify({ code: providerId, platform: 'wb' }), 258 | headers: { 259 | 'Content-Type': 'application/json', 260 | Authorization: `Bearer ${userOneAccessToken}` 261 | } 262 | }) 263 | expect(res.status).toBe(httpStatus.BAD_REQUEST) 264 | }) 265 | }) 266 | 267 | describe('DELETE /v1/auth/github/:userId', () => { 268 | test('should return 200 and successfully remove github account link', async () => { 269 | await insertUsers([userOne], config.database) 270 | const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) 271 | const githubUser = githubAuthorisation(userOne.id) 272 | await insertAuthorisations([githubUser], config.database) 273 | 274 | const res = await request(`/v1/auth/github/${userOne.id}`, { 275 | method: 'DELETE', 276 | headers: { 277 | Authorization: `Bearer ${userOneAccessToken}` 278 | } 279 | }) 280 | expect(res.status).toBe(httpStatus.NO_CONTENT) 281 | 282 | const oauthUser = await client 283 | .selectFrom('authorisations') 284 | .selectAll() 285 | .where('authorisations.provider_type', '=', authProviders.GITHUB) 286 | .where('authorisations.user_id', '=', userOne.id) 287 | .executeTakeFirst() 288 | 289 | expect(oauthUser).toBeUndefined() 290 | if (!oauthUser) return 291 | }) 292 | 293 | test('should return 400 if user does not have a local login and only 1 link', async () => { 294 | const newUser = { ...userOne, password: null } 295 | await insertUsers([newUser], config.database) 296 | const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt) 297 | const githubUser = githubAuthorisation(newUser.id) 298 | await insertAuthorisations([githubUser], config.database) 299 | 300 | const res = await request(`/v1/auth/github/${newUser.id}`, { 301 | method: 'DELETE', 302 | headers: { 303 | Authorization: `Bearer ${userOneAccessToken}` 304 | } 305 | }) 306 | expect(res.status).toBe(httpStatus.BAD_REQUEST) 307 | 308 | const oauthUser = await client 309 | .selectFrom('authorisations') 310 | .selectAll() 311 | .where('authorisations.provider_type', '=', authProviders.GITHUB) 312 | .where('authorisations.user_id', '=', newUser.id) 313 | .executeTakeFirst() 314 | 315 | expect(oauthUser).toBeDefined() 316 | }) 317 | 318 | test('should return 400 if user does not have github link', async () => { 319 | const newUser = { ...userOne, password: null } 320 | await insertUsers([newUser], config.database) 321 | const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt) 322 | const googleUser = googleAuthorisation(newUser.id) 323 | await insertAuthorisations([googleUser], config.database) 324 | const appleUser = appleAuthorisation(newUser.id) 325 | await insertAuthorisations([appleUser], config.database) 326 | 327 | const res = await request(`/v1/auth/github/${newUser.id}`, { 328 | method: 'DELETE', 329 | headers: { 330 | Authorization: `Bearer ${userOneAccessToken}` 331 | } 332 | }) 333 | expect(res.status).toBe(httpStatus.BAD_REQUEST) 334 | }) 335 | 336 | test('should return 400 if user only has a local login', async () => { 337 | const newUser = { ...userOne, password: null } 338 | await insertUsers([newUser], config.database) 339 | const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt) 340 | 341 | const res = await request(`/v1/auth/github/${newUser.id}`, { 342 | method: 'DELETE', 343 | headers: { 344 | Authorization: `Bearer ${userOneAccessToken}` 345 | } 346 | }) 347 | expect(res.status).toBe(httpStatus.BAD_REQUEST) 348 | }) 349 | 350 | test('should return 200 if user does not have a local login and 2 links', async () => { 351 | const newUser = { ...userOne, password: null } 352 | await insertUsers([newUser], config.database) 353 | const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt) 354 | const githubUser = githubAuthorisation(newUser.id) 355 | const appleUser = appleAuthorisation(newUser.id) 356 | await insertAuthorisations([githubUser, appleUser], config.database) 357 | 358 | const res = await request(`/v1/auth/github/${newUser.id}`, { 359 | method: 'DELETE', 360 | headers: { 361 | Authorization: `Bearer ${userOneAccessToken}` 362 | } 363 | }) 364 | expect(res.status).toBe(httpStatus.NO_CONTENT) 365 | 366 | const oauthGithubUser = await client 367 | .selectFrom('authorisations') 368 | .selectAll() 369 | .where('authorisations.provider_type', '=', authProviders.GITHUB) 370 | .where('authorisations.user_id', '=', newUser.id) 371 | .executeTakeFirst() 372 | 373 | expect(oauthGithubUser).toBeUndefined() 374 | 375 | const oauthFacebookUser = await client 376 | .selectFrom('authorisations') 377 | .selectAll() 378 | .where('authorisations.provider_type', '=', authProviders.APPLE) 379 | .where('authorisations.user_id', '=', newUser.id) 380 | .executeTakeFirst() 381 | 382 | expect(oauthFacebookUser).toBeDefined() 383 | }) 384 | 385 | test('should return 403 if unlinking different user', async () => { 386 | await insertUsers([userOne], config.database) 387 | const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt) 388 | 389 | const res = await request('/v1/auth/github/5298', { 390 | method: 'DELETE', 391 | headers: { 392 | 'Content-Type': 'application/json', 393 | Authorization: `Bearer ${userOneAccessToken}` 394 | } 395 | }) 396 | expect(res.status).toBe(httpStatus.FORBIDDEN) 397 | }) 398 | 399 | test('should return 401 error if access token is missing', async () => { 400 | const res = await request('/v1/auth/github/1234', { 401 | method: 'DELETE', 402 | headers: { 403 | 'Content-Type': 'application/json' 404 | } 405 | }) 406 | expect(res.status).toBe(httpStatus.UNAUTHORIZED) 407 | }) 408 | test('should return 403 if user has not verified their email', async () => { 409 | await insertUsers([userTwo], config.database) 410 | const accessToken = await getAccessToken( 411 | userTwo.id, 412 | userTwo.role, 413 | config.jwt, 414 | tokenTypes.ACCESS, 415 | userTwo.is_email_verified 416 | ) 417 | const res = await request('/v1/auth/github/5298', { 418 | method: 'DELETE', 419 | headers: { 420 | 'Content-Type': 'application/json', 421 | Authorization: `Bearer ${accessToken}` 422 | } 423 | }) 424 | expect(res.status).toBe(httpStatus.FORBIDDEN) 425 | }) 426 | }) 427 | 428 | describe('POST /v1/auth/github/callback', () => { 429 | let newUser: GithubUserType 430 | beforeAll(async () => { 431 | newUser = { 432 | id: faker.number.int(), 433 | name: faker.person.fullName(), 434 | email: faker.internet.email() 435 | } 436 | fetchMock.activate() 437 | }) 438 | afterEach(async () => fetchMock.assertNoPendingInterceptors()) 439 | test('should return 200 and successfully register user if request data is ok', async () => { 440 | const githubApiMock = fetchMock.get('https://api.github.com') 441 | githubApiMock.intercept({ method: 'GET', path: '/user' }).reply(200, JSON.stringify(newUser)) 442 | const githubMock = fetchMock.get('https://github.com') 443 | githubMock 444 | .intercept({ method: 'POST', path: '/login/oauth/access_token' }) 445 | .reply(200, JSON.stringify({ access_token: '1234' })) 446 | 447 | const providerId = '123456' 448 | const res = await request('/v1/auth/github/callback', { 449 | method: 'POST', 450 | body: JSON.stringify({ code: providerId, platform: 'web' }), 451 | headers: { 452 | 'Content-Type': 'application/json' 453 | } 454 | }) 455 | const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>() 456 | expect(res.status).toBe(httpStatus.OK) 457 | expect(body.user).not.toHaveProperty('password') 458 | expect(body.user).toEqual({ 459 | id: expect.anything(), 460 | name: newUser.name, 461 | email: newUser.email, 462 | role: 'user', 463 | is_email_verified: 1 464 | }) 465 | 466 | const dbUser = await client 467 | .selectFrom('user') 468 | .selectAll() 469 | .where('user.id', '=', body.user.id) 470 | .executeTakeFirst() 471 | 472 | expect(dbUser).toBeDefined() 473 | if (!dbUser) return 474 | 475 | expect(dbUser.password).toBeNull() 476 | expect(dbUser).toMatchObject({ 477 | name: newUser.name, 478 | password: null, 479 | email: newUser.email, 480 | role: 'user', 481 | is_email_verified: 1 482 | }) 483 | 484 | const oauthUser = await client 485 | .selectFrom('authorisations') 486 | .selectAll() 487 | .where('authorisations.provider_type', '=', authProviders.GITHUB) 488 | .where('authorisations.user_id', '=', body.user.id) 489 | .where('authorisations.provider_user_id', '=', String(newUser.id)) 490 | .executeTakeFirst() 491 | 492 | expect(oauthUser).toBeDefined() 493 | if (!oauthUser) return 494 | 495 | expect(body.tokens).toEqual({ 496 | access: { token: expect.anything(), expires: expect.anything() }, 497 | refresh: { token: expect.anything(), expires: expect.anything() } 498 | }) 499 | }) 500 | 501 | test('should return 200 and successfully login user if already created', async () => { 502 | await insertUsers([userOne], config.database) 503 | const githubUser = githubAuthorisation(userOne.id) 504 | await insertAuthorisations([githubUser], config.database) 505 | newUser.id = parseInt(githubUser.provider_user_id) 506 | const githubApiMock = fetchMock.get('https://api.github.com') 507 | githubApiMock.intercept({ method: 'GET', path: '/user' }).reply(200, JSON.stringify(newUser)) 508 | const githubMock = fetchMock.get('https://github.com') 509 | githubMock 510 | .intercept({ method: 'POST', path: '/login/oauth/access_token' }) 511 | .reply(200, JSON.stringify({ access_token: '1234' })) 512 | 513 | const providerId = '123456' 514 | const res = await request('/v1/auth/github/callback', { 515 | method: 'POST', 516 | body: JSON.stringify({ code: providerId, platform: 'web' }), 517 | headers: { 518 | 'Content-Type': 'application/json' 519 | } 520 | }) 521 | const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>() 522 | expect(res.status).toBe(httpStatus.OK) 523 | expect(body.user).not.toHaveProperty('password') 524 | expect(body.user).toEqual({ 525 | id: userOne.id, 526 | name: userOne.name, 527 | email: userOne.email, 528 | role: userOne.role, 529 | is_email_verified: 0 530 | }) 531 | 532 | expect(body.tokens).toEqual({ 533 | access: { token: expect.anything(), expires: expect.anything() }, 534 | refresh: { token: expect.anything(), expires: expect.anything() } 535 | }) 536 | }) 537 | 538 | test('should return 403 if user exists but has not linked their github', async () => { 539 | await insertUsers([userOne], config.database) 540 | newUser.email = userOne.email 541 | const githubApiMock = fetchMock.get('https://api.github.com') 542 | githubApiMock.intercept({ method: 'GET', path: '/user' }).reply(200, JSON.stringify(newUser)) 543 | const githubMock = fetchMock.get('https://github.com') 544 | githubMock 545 | .intercept({ method: 'POST', path: '/login/oauth/access_token' }) 546 | .reply(200, JSON.stringify({ access_token: '1234' })) 547 | 548 | const providerId = '123456' 549 | const res = await request('/v1/auth/github/callback', { 550 | method: 'POST', 551 | body: JSON.stringify({ code: providerId, platform: 'web' }), 552 | headers: { 553 | 'Content-Type': 'application/json' 554 | } 555 | }) 556 | const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>() 557 | expect(res.status).toBe(httpStatus.FORBIDDEN) 558 | expect(body).toEqual({ 559 | code: httpStatus.FORBIDDEN, 560 | message: 'Cannot signup with github, user already exists with that email' 561 | }) 562 | }) 563 | 564 | test('should return 401 if code is invalid', async () => { 565 | const providerId = '123456' 566 | const res = await request('/v1/auth/github/callback', { 567 | method: 'POST', 568 | body: JSON.stringify({ code: providerId, platform: 'web' }), 569 | headers: { 570 | 'Content-Type': 'application/json' 571 | } 572 | }) 573 | expect(res.status).toBe(httpStatus.UNAUTHORIZED) 574 | }) 575 | 576 | test('should return 400 if no code provided', async () => { 577 | const res = await request('/v1/auth/github/callback', { 578 | method: 'POST', 579 | body: JSON.stringify({}), 580 | headers: { 581 | 'Content-Type': 'application/json' 582 | } 583 | }) 584 | expect(res.status).toBe(httpStatus.BAD_REQUEST) 585 | }) 586 | test('should return 400 error if platform is not provided', async () => { 587 | const providerId = '123456' 588 | const res = await request('/v1/auth/github/callback', { 589 | method: 'POST', 590 | body: JSON.stringify({ code: providerId }), 591 | headers: { 592 | 'Content-Type': 'application/json' 593 | } 594 | }) 595 | expect(res.status).toBe(httpStatus.BAD_REQUEST) 596 | }) 597 | test('should return 400 error if platform is invalid', async () => { 598 | const providerId = '123456' 599 | const res = await request('/v1/auth/github/callback', { 600 | method: 'POST', 601 | body: JSON.stringify({ code: providerId, platform: 'wb' }), 602 | headers: { 603 | 'Content-Type': 'application/json' 604 | } 605 | }) 606 | expect(res.status).toBe(httpStatus.BAD_REQUEST) 607 | }) 608 | }) 609 | }) 610 | --------------------------------------------------------------------------------