├── .nvmrc ├── src ├── api │ ├── org │ │ ├── entities │ │ │ └── org.entity.ts │ │ ├── dto │ │ │ ├── update-org.dto.ts │ │ │ └── create-org.dto.ts │ │ ├── org.module.ts │ │ ├── org.service.spec.ts │ │ └── org.service.ts │ └── users │ │ ├── entities │ │ └── user.entity.ts │ │ ├── dto │ │ ├── update-user.dto.ts │ │ └── create-user.dto.ts │ │ ├── users.module.ts │ │ ├── users.service.spec.ts │ │ ├── users.controller.spec.ts │ │ ├── users.controller.ts │ │ └── users.service.ts ├── utils │ ├── auth │ │ ├── dto │ │ │ ├── login.dto.ts │ │ │ └── password-reset.dto.ts │ │ ├── types.ts │ │ ├── bcrypt.ts │ │ ├── strategies │ │ │ ├── local.strategy.ts │ │ │ ├── jwt.strategy.ts │ │ │ └── refreshToken.strategy.ts │ │ ├── auth.service.spec.ts │ │ ├── guards │ │ │ ├── local-auth.guard.ts │ │ │ ├── jwt-auth.guard.ts │ │ │ └── refresh-token.guard.ts │ │ ├── auth.controller.spec.ts │ │ ├── auth.module.ts │ │ ├── auth.controller.ts │ │ └── auth.service.ts │ ├── crypto.ts │ ├── api-key │ │ ├── dto │ │ │ ├── update-api-key.dto.ts │ │ │ ├── create-api-key-success.dto.ts │ │ │ └── create-api-key.dto.ts │ │ ├── api-key.service.spec.ts │ │ ├── guards │ │ │ └── api-key-auth.guard.ts │ │ └── api-key.service.ts │ ├── email │ │ ├── email.module.ts │ │ └── email.service.ts │ ├── prisma │ │ ├── prisma.service.ts │ │ └── prisma.service.spec.ts │ ├── header.ts │ ├── config │ │ └── config.ts │ └── middleware │ │ └── domain-filter.middleware.ts ├── app │ ├── app.service.ts │ ├── app.controller.ts │ ├── app.controller.spec.ts │ └── app.module.ts └── main.ts ├── .prettierrc ├── .vscode ├── extensions.json ├── settings.json ├── launch.json └── tasks.json ├── tsconfig.build.json ├── prisma ├── migrations │ ├── migration_lock.toml │ └── 20230927190631_db_init │ │ └── migration.sql └── schema.prisma ├── nest-cli.json ├── railway.toml ├── test ├── jest-e2e.json └── app.e2e-spec.ts ├── .dockerignore ├── .gitignore ├── .docker.env.example ├── .env.example ├── .eslintrc.js ├── tsconfig.json ├── docker-compose.yml ├── Dockerfile ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.12.1 2 | 3 | -------------------------------------------------------------------------------- /src/api/org/entities/org.entity.ts: -------------------------------------------------------------------------------- 1 | export class Org {} 2 | -------------------------------------------------------------------------------- /src/api/users/entities/user.entity.ts: -------------------------------------------------------------------------------- 1 | export class User {} 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/auth/dto/login.dto.ts: -------------------------------------------------------------------------------- 1 | export class EmailPasswordLoginDto { 2 | email: string; 3 | password: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/app/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class AppService {} 5 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/api/users/dto/update-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType } from '@nestjs/swagger'; 2 | import { CreateUserDto } from './create-user.dto'; 3 | 4 | export class UpdateUserDto extends PartialType(CreateUserDto) {} 5 | -------------------------------------------------------------------------------- /src/api/org/dto/update-org.dto.ts: -------------------------------------------------------------------------------- 1 | import { CreateOrgDto } from '@api/org/dto/create-org.dto'; 2 | import { PartialType } from '@nestjs/swagger'; 3 | 4 | export class UpdateOrgDto extends PartialType(CreateOrgDto) {} 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": ["adminer", "Fastify", "HVAC", "nestjs", "wattshift"], 3 | "debug.allowBreakpointsEverywhere": true, 4 | "typescript.preferences.importModuleSpecifier": "non-relative" 5 | } 6 | -------------------------------------------------------------------------------- /railway.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | builder = "DOCKERFILE" 3 | dockerfilePath = "Dockerfile" 4 | 5 | [deploy] 6 | numReplicas = 1 7 | restartPolicyType = "ON_FAILURE" 8 | restartPolicyMaxRetries = 3 9 | healthcheckPath = "/v1/health" 10 | healthcheckTimeout = 30 11 | -------------------------------------------------------------------------------- /src/utils/crypto.ts: -------------------------------------------------------------------------------- 1 | import { randomBytes } from 'crypto'; 2 | import { promisify } from 'util'; 3 | 4 | /** 5 | * Returns a promise that resolves to a buffer of cryptographically strong pseudo-random data. 6 | */ 7 | export const randomBytesAsync = promisify(randomBytes); 8 | -------------------------------------------------------------------------------- /src/utils/api-key/dto/update-api-key.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType } from '@nestjs/swagger'; 2 | import { CreateApiKeyDto } from './create-api-key.dto'; 3 | // import { ApiProperty } from '@nestjs/swagger'; 4 | 5 | export class UpdateApiKeyDto extends PartialType(CreateApiKeyDto) {} 6 | -------------------------------------------------------------------------------- /src/api/org/org.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { OrgService } from './org.service'; 3 | import { PrismaService } from '@utils/prisma/prisma.service'; 4 | 5 | @Module({ 6 | exports: [OrgService], 7 | providers: [OrgService, PrismaService], 8 | }) 9 | export class OrgModule {} 10 | -------------------------------------------------------------------------------- /src/utils/api-key/dto/create-api-key-success.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class CreateApiKeyResponseDto { 4 | @ApiProperty() 5 | apiKey: string; 6 | @ApiProperty() 7 | success: boolean; 8 | @ApiProperty() 9 | message: string; 10 | @ApiProperty() 11 | ownerEmail: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/email/email.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { EmailService } from '@utils/email/email.service'; 4 | 5 | @Module({ 6 | imports: [ConfigModule], 7 | providers: [EmailService], 8 | exports: [EmailService], 9 | }) 10 | export class EmailModule {} 11 | -------------------------------------------------------------------------------- /src/utils/api-key/dto/create-api-key.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsString, Length } from 'class-validator'; 3 | export class CreateApiKeyDto { 4 | @ApiProperty() 5 | @IsString() 6 | @Length(20, 26) 7 | userId: string; 8 | 9 | @ApiProperty() 10 | @IsString() 11 | @Length(20, 26) 12 | organizationId: string; 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/prisma/prisma.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; 2 | import { PrismaClient } from '@prisma/client'; 3 | 4 | @Injectable() 5 | export class PrismaService 6 | extends PrismaClient 7 | implements OnModuleInit, OnModuleDestroy 8 | { 9 | async onModuleInit() { 10 | await this.$connect(); 11 | } 12 | 13 | async onModuleDestroy() { 14 | await this.$disconnect(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Start Debug Server", 6 | "type": "node", 7 | "request": "launch", 8 | "runtimeExecutable": "npm", 9 | "preLaunchTask": "docker-compose-up", 10 | "postDebugTask": "docker-compose-down", 11 | "runtimeArgs": ["run", "start:dev"], 12 | 13 | "cwd": "${workspaceFolder}", 14 | "console": "integratedTerminal" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | }, 9 | "roots": ["/"], 10 | "moduleNameMapper": { 11 | "^@api(.*)$": "/src/api/$1", 12 | "^@client(.*)$": "/src/client/$1", 13 | "^@utils(.*)$": "/src/utils/$1", 14 | "^@app(.*)$": "/src/app/$1" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "docker-compose-up", 6 | "type": "shell", 7 | "command": "docker-compose up -d db adminer", 8 | "group": { 9 | "kind": "build", 10 | "isDefault": true 11 | } 12 | }, 13 | { 14 | "label": "docker-compose-down", 15 | "type": "shell", 16 | "command": "docker-compose down", 17 | "group": { 18 | "kind": "build", 19 | "isDefault": true 20 | } 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | .env 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | pnpm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | 15 | # OS 16 | .DS_Store 17 | 18 | # Tests 19 | /coverage 20 | /.nyc_output 21 | 22 | # IDEs and editors 23 | /.idea 24 | .project 25 | .classpath 26 | .c9/ 27 | *.launch 28 | .settings/ 29 | *.sublime-workspace 30 | 31 | # IDE - VSCode 32 | .vscode/* 33 | !.vscode/settings.json 34 | !.vscode/tasks.json 35 | !.vscode/launch.json 36 | !.vscode/extensions.json -------------------------------------------------------------------------------- /src/utils/prisma/prisma.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { PrismaService } from './prisma.service'; 3 | 4 | describe('PrismaService', () => { 5 | let service: PrismaService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [PrismaService], 10 | }).compile(); 11 | 12 | service = module.get(PrismaService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/api/users/users.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ApiKeyService } from '@utils/api-key/api-key.service'; 3 | import { BcryptService } from '@utils/auth/bcrypt'; 4 | import { PrismaService } from '@utils/prisma/prisma.service'; 5 | import { UsersController } from './users.controller'; 6 | import { UsersService } from './users.service'; 7 | 8 | @Module({ 9 | exports: [UsersService], 10 | controllers: [UsersController], 11 | 12 | providers: [UsersService, PrismaService, BcryptService, ApiKeyService], 13 | }) 14 | export class UsersModule {} 15 | -------------------------------------------------------------------------------- /src/api/org/dto/create-org.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; 3 | 4 | export class CreateOrgDto { 5 | @ApiProperty({ 6 | description: 7 | 'The domain of the organization (i.e. some written unique identifier).', 8 | required: true, 9 | }) 10 | @IsNotEmpty() 11 | @IsString() 12 | domain: string; 13 | 14 | @ApiProperty({ 15 | required: false, 16 | description: 'The name of the organization.', 17 | }) 18 | @IsOptional() 19 | @IsString() 20 | name?: string; 21 | } 22 | -------------------------------------------------------------------------------- /src/app/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Headers, Logger } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | 4 | @Controller() 5 | export class AppController { 6 | constructor(private readonly appService: AppService) {} 7 | private readonly logger = new Logger(AppController.name); 8 | 9 | @Get('/health') 10 | getHealth(@Headers() headers?): string { 11 | this.logger.log( 12 | `Health check from host: ${headers?.host ?? 'null'}. Referrer: ${ 13 | headers?.referer ?? 'null' 14 | }`, 15 | ); 16 | return 'OK'; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Environment variables 6 | .env 7 | .docker.env 8 | 9 | # Logs 10 | logs 11 | *.log 12 | npm-debug.log* 13 | pnpm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | lerna-debug.log* 17 | 18 | # OS 19 | .DS_Store 20 | 21 | # Tests 22 | /coverage 23 | /.nyc_output 24 | 25 | # IDEs and editors 26 | /.idea 27 | .project 28 | .classpath 29 | .c9/ 30 | *.launch 31 | .settings/ 32 | *.sublime-workspace 33 | 34 | # IDE - VSCode 35 | .vscode/* 36 | !.vscode/settings.json 37 | !.vscode/tasks.json 38 | !.vscode/launch.json 39 | !.vscode/extensions.json -------------------------------------------------------------------------------- /src/api/org/org.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { OrgService } from './org.service'; 3 | import { PrismaService } from '@utils/prisma/prisma.service'; 4 | 5 | describe('OrgService', () => { 6 | let service: OrgService; 7 | 8 | beforeEach(async () => { 9 | const module: TestingModule = await Test.createTestingModule({ 10 | providers: [OrgService, PrismaService], 11 | }).compile(); 12 | 13 | service = module.get(OrgService); 14 | }); 15 | 16 | it('should be defined', () => { 17 | expect(service).toBeDefined(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/utils/auth/types.ts: -------------------------------------------------------------------------------- 1 | import { Request } from '@nestjs/common'; 2 | import { ApiKey, User } from '@prisma/client'; 3 | 4 | export type jwtPayload = { 5 | username: string; 6 | sub: string; 7 | }; 8 | 9 | export type validatedJwtUserInfo = { 10 | userId: string; 11 | email: string; 12 | refreshToken?: string; 13 | }; 14 | 15 | export interface JwtAuthenticatedRequest extends Request { 16 | user: validatedJwtUserInfo; 17 | } 18 | 19 | export interface PasswordAuthenticatedRequest extends Request { 20 | user: User; 21 | } 22 | 23 | export interface AuthenticatedRequest extends Request { 24 | apiKey: ApiKey; 25 | } 26 | -------------------------------------------------------------------------------- /src/utils/api-key/api-key.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ApiKeyService } from './api-key.service'; 3 | import { PrismaService } from '@utils/prisma/prisma.service'; 4 | 5 | describe('ApiKeyService', () => { 6 | let service: ApiKeyService; 7 | 8 | beforeEach(async () => { 9 | const module: TestingModule = await Test.createTestingModule({ 10 | providers: [ApiKeyService, PrismaService], 11 | }).compile(); 12 | 13 | service = module.get(ApiKeyService); 14 | }); 15 | 16 | it('should be defined', () => { 17 | expect(service).toBeDefined(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/api/users/dto/create-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsEmail, IsOptional, IsTimeZone, Length } from 'class-validator'; 3 | 4 | export class CreateUserDto { 5 | @ApiProperty() 6 | @IsEmail() 7 | email: string; 8 | 9 | @ApiProperty() 10 | @Length(8, 32) 11 | password: string; 12 | 13 | @ApiProperty({ required: false }) 14 | @IsOptional() 15 | name?: string; 16 | 17 | @ApiProperty({ required: false }) 18 | @IsOptional() 19 | username?: string; 20 | 21 | @ApiProperty({ required: false }) 22 | @IsTimeZone() 23 | @IsOptional() 24 | timezone?: string; 25 | 26 | refreshToken?: string; 27 | organizationId: string; 28 | } 29 | -------------------------------------------------------------------------------- /.docker.env.example: -------------------------------------------------------------------------------- 1 | # The timeout for the database connection in needed to make the docker config work 2 | # Also note that the host is `postgres` and not `localhost` for .docker.env! 3 | # You need both .env and .docker.env to make it work for both docker and non-docker environments locally. 4 | # In production, only the actual env variables injected into the runtime environment are used. 5 | 6 | DATABASE_URL="postgresql://postgres:example@postgres:5432/db?schema=public?connect_timeout=10" 7 | DIRECT_URL="postgresql://postgres:example@postgres:5432/db?schema=public?connect_timeout=10" 8 | 9 | ## All other variables are the same as in the .env.example, just the DATABASE_URL and DIRECT_URL are different 10 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # The timeout for the database connection in needed to make the docker config work 2 | # You need both .env and .docker.env to make it work for both docker and non-docker environments. 3 | # See https://console.neon.tech/app/projects/silent-pond-46937014 for the database urls 4 | DATABASE_URL="postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public?connect_timeout=300" 5 | DIRECT_URL="postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public?connect_timeout=300" 6 | JWT_ACCESS_SECRET="some-long-secret-value-like-uuid" 7 | JWT_REFRESH_SECRET="some-other-long-secret-value-like-uuid" 8 | FRONTEND_URL='localhost:3000' # This is the url of the frontend, used for CORS 9 | RESEND_API_KEY="resend-api-key" -------------------------------------------------------------------------------- /src/api/users/users.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { UsersService } from './users.service'; 3 | import { PrismaService } from '@utils/prisma/prisma.service'; 4 | import { BcryptService } from '@utils/auth/bcrypt'; 5 | 6 | describe('UsersService', () => { 7 | let service: UsersService; 8 | 9 | beforeEach(async () => { 10 | const module: TestingModule = await Test.createTestingModule({ 11 | providers: [UsersService, PrismaService, BcryptService], 12 | }).compile(); 13 | 14 | service = module.get(UsersService); 15 | }); 16 | 17 | it('should be defined', () => { 18 | expect(service).toBeDefined(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/utils/auth/dto/password-reset.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsEmail, IsOptional, IsString } from 'class-validator'; 3 | 4 | export class PasswordResetDto { 5 | @ApiProperty({ required: true }) 6 | @IsEmail() 7 | email: string; 8 | 9 | @IsString() 10 | @IsOptional() 11 | username?: string; 12 | } 13 | 14 | export class CheckPasswordResetDto { 15 | @ApiProperty({ required: true }) 16 | @IsEmail() 17 | email: string; 18 | 19 | @ApiProperty({ required: true }) 20 | @IsString() 21 | resetToken: string; 22 | } 23 | 24 | export class PasswordResetResponseDto { 25 | @ApiProperty() 26 | message: string; 27 | @ApiProperty() 28 | valid: boolean; 29 | } 30 | -------------------------------------------------------------------------------- /src/app/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | 5 | describe('AppController', () => { 6 | let appController: AppController; 7 | 8 | beforeEach(async () => { 9 | const app: TestingModule = await Test.createTestingModule({ 10 | controllers: [AppController], 11 | providers: [AppService], 12 | }).compile(); 13 | 14 | appController = app.get(AppController); 15 | }); 16 | 17 | describe('root', () => { 18 | it('should return "Hello World!"', () => { 19 | expect(appController.getHealth()).toBe('OK'); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { AppModule } from '@app/app.module'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import { Test, TestingModule } from '@nestjs/testing'; 4 | import * as request from 'supertest'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /src/utils/header.ts: -------------------------------------------------------------------------------- 1 | import { ApiBearerAuth, ApiHeader } from '@nestjs/swagger'; 2 | 3 | export const SHOW_CONTROLLER_IN_SWAGGER = 4 | (process.env.NODE_ENV || 'development') !== 'development'; 5 | 6 | export const sJwtBearer = 'JWT-auth'; 7 | export const sApiKeyBearer = 'x-api-key'; 8 | /** 9 | * A swagger decorator for the WattShift JWT header. 10 | */ 11 | export const JwtHeader = ApiHeader({ 12 | name: sJwtBearer, 13 | description: 'JWT', 14 | }); 15 | /** 16 | * A swagger decorator for the WattShift API key header. 17 | */ 18 | export const ApiKeyHeader = ApiHeader({ 19 | name: sApiKeyBearer, 20 | description: 'API Key', 21 | }); 22 | 23 | /** 24 | * Indicates that the frontend JWT is required for this endpoint. 25 | */ 26 | export const JwtBearer = ApiBearerAuth(sJwtBearer); 27 | -------------------------------------------------------------------------------- /src/utils/auth/bcrypt.ts: -------------------------------------------------------------------------------- 1 | import * as bcrypt from 'bcrypt'; 2 | import { Injectable } from '@nestjs/common'; 3 | 4 | @Injectable() 5 | export class BcryptService { 6 | /** 7 | * Generate a secure hash (including a salt) for a password 8 | * By default, uses 10 rounds of salt, which is a good balance between security and performance 9 | * On a 2GHz machine, 10 rounds takes about 100ms to complete (10 hashes per second) 10 | * @param password 11 | * @returns 12 | */ 13 | async hash(password: string) { 14 | return bcrypt.hash(password, 10); 15 | } 16 | 17 | /** 18 | * Checks if a given password matches a given hash 19 | * @param password 20 | * @param hash 21 | * @returns 22 | */ 23 | async compare(password: string, hash: string) { 24 | return bcrypt.compare(password, hash); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/config/config.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'joi'; 2 | 3 | export interface EnvironmentVariables { 4 | JWT_REFRESH_SECRET: string; 5 | JWT_ACCESS_SECRET: string; 6 | DATABASE_URL: string; 7 | DIRECT_URL: string; 8 | PORT: number; 9 | NODE_ENV: string; 10 | FRONTEND_URL: string; 11 | RESEND_API_KEY: string; 12 | } 13 | 14 | export const configValidationSchema = Joi.object({ 15 | JWT_REFRESH_SECRET: Joi.string().required(), 16 | JWT_ACCESS_SECRET: Joi.string().required(), 17 | DATABASE_URL: Joi.string().required(), 18 | DIRECT_URL: Joi.string().required(), 19 | PORT: Joi.number().default(3000), 20 | NODE_ENV: Joi.string() 21 | .valid('development', 'production', 'test', 'provision') 22 | .default('development'), 23 | FRONTEND_URL: Joi.string().required(), 24 | RESEND_API_KEY: Joi.string().optional(), 25 | }); 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2021", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "paths": { 14 | "@app/*": ["src/app/*"], 15 | "@api/*": ["src/api/*"], 16 | "@client/*": ["src/client/*"], 17 | "@utils/*": ["src/utils/*"] 18 | }, 19 | "incremental": true, 20 | "skipLibCheck": true, 21 | "strictNullChecks": false, 22 | "noImplicitAny": false, 23 | "strictBindCallApply": false, 24 | "forceConsistentCasingInFileNames": true, 25 | "noFallthroughCasesInSwitch": false, 26 | "strict": true 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/utils/auth/strategies/local.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Strategy } from 'passport-local'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 4 | import { AuthService } from '../auth.service'; 5 | import { User } from '@prisma/client'; 6 | 7 | @Injectable() 8 | export class LocalStrategy extends PassportStrategy(Strategy) { 9 | constructor(private readonly authService: AuthService) { 10 | super({ usernameField: 'email' }); 11 | } 12 | 13 | async validate(username: string, password: string): Promise { 14 | const user = await this.authService.validateUser(username, password); 15 | if (!user) { 16 | throw new UnauthorizedException( 17 | `Error: Incorrect password for user with email ${username}`, 18 | ); 19 | } 20 | return user; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/api/users/users.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { UsersController } from './users.controller'; 3 | import { UsersService } from './users.service'; 4 | import { PrismaService } from '@utils/prisma/prisma.service'; 5 | 6 | import { BcryptService } from '@utils/auth/bcrypt'; 7 | import { ApiKeyService } from '@utils/api-key/api-key.service'; 8 | 9 | describe('UsersController', () => { 10 | let controller: UsersController; 11 | 12 | beforeEach(async () => { 13 | const module: TestingModule = await Test.createTestingModule({ 14 | controllers: [UsersController], 15 | providers: [UsersService, PrismaService, BcryptService, ApiKeyService], 16 | }).compile(); 17 | 18 | controller = module.get(UsersController); 19 | }); 20 | 21 | it('should be defined', () => { 22 | expect(controller).toBeDefined(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/utils/email/email.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { EnvironmentVariables } from '@utils/config/config'; 4 | import { Resend } from 'resend'; 5 | 6 | /** 7 | * A wrapper around the Resend API client. 8 | * @see https://resend.com/docs/introduction 9 | */ 10 | @Injectable() 11 | export class EmailService extends Resend { 12 | private logger = new Logger(EmailService.name); 13 | constructor( 14 | private readonly configService: ConfigService, 15 | ) { 16 | const defaultKey = 'temp'; 17 | const apiKey = 18 | configService.get('RESEND_API_KEY', { infer: true }) || defaultKey; 19 | 20 | super(apiKey); 21 | if (apiKey === defaultKey) { 22 | this.logger.error( 23 | 'RESEND_API_KEY is set to the default value. Emails will not be sent.', 24 | ); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/utils/auth/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { JwtService } from '@nestjs/jwt'; 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | import { PrismaService } from '@utils/prisma/prisma.service'; 4 | import { UsersService } from '@api/users/users.service'; 5 | import { AuthService } from './auth.service'; 6 | import { BcryptService } from '@utils/auth/bcrypt'; 7 | import { ConfigModule } from '@nestjs/config'; 8 | 9 | describe('AuthService', () => { 10 | let service: AuthService; 11 | 12 | beforeEach(async () => { 13 | const module: TestingModule = await Test.createTestingModule({ 14 | providers: [ 15 | AuthService, 16 | UsersService, 17 | PrismaService, 18 | JwtService, 19 | BcryptService, 20 | ], 21 | imports: [ConfigModule], 22 | }).compile(); 23 | 24 | service = module.get(AuthService); 25 | }); 26 | 27 | it('should be defined', () => { 28 | expect(service).toBeDefined(); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/utils/auth/strategies/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { PassportStrategy } from '@nestjs/passport'; 4 | import { ExtractJwt, Strategy } from 'passport-jwt'; 5 | import { jwtPayload, validatedJwtUserInfo } from '../types'; 6 | import { EnvironmentVariables } from '@utils/config/config'; 7 | 8 | @Injectable() 9 | export class JwtStrategy extends PassportStrategy(Strategy) { 10 | constructor( 11 | private readonly configService: ConfigService, 12 | ) { 13 | super({ 14 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 15 | ignoreExpiration: false, 16 | secretOrKey: configService.get('JWT_ACCESS_SECRET', { infer: true }), 17 | }); 18 | } 19 | 20 | async validate(payload: jwtPayload): Promise { 21 | return { 22 | userId: payload.sub, 23 | email: payload.username, 24 | } as validatedJwtUserInfo; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/middleware/domain-filter.middleware.ts: -------------------------------------------------------------------------------- 1 | import { EnvironmentVariables } from '@utils/config/config'; 2 | import { ForbiddenException, Injectable, NestMiddleware } from '@nestjs/common'; 3 | import { ConfigService } from '@nestjs/config'; 4 | import { FastifyRequest } from 'fastify'; 5 | 6 | @Injectable() 7 | export class FastifyDomainFilterMiddleware implements NestMiddleware { 8 | private readonly allowedDomains: string; 9 | 10 | constructor( 11 | private readonly configService: ConfigService, 12 | ) { 13 | // Add your allowed domains here 14 | this.allowedDomains = configService.get('FRONTEND_URL', { infer: true }); 15 | } 16 | 17 | use(req: FastifyRequest, res: any, next: () => void) { 18 | const origin = req.headers.host; 19 | 20 | if (!this.allowedDomains.includes(origin)) { 21 | throw new ForbiddenException({ 22 | message: `Cannot make requests to this route from the ${origin} domain`, 23 | }); 24 | } 25 | 26 | next(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/utils/auth/strategies/refreshToken.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { PassportStrategy } from '@nestjs/passport'; 4 | import { jwtPayload, validatedJwtUserInfo } from '@utils/auth/types'; 5 | import { EnvironmentVariables } from '@utils/config/config'; 6 | import { ExtractJwt, Strategy } from 'passport-jwt'; 7 | 8 | @Injectable() 9 | export class RefreshTokenStrategy extends PassportStrategy( 10 | Strategy, 11 | 'jwt-refresh', 12 | ) { 13 | constructor( 14 | private readonly configService: ConfigService, 15 | ) { 16 | super({ 17 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 18 | ignoreExpiration: false, 19 | secretOrKey: configService.get('JWT_REFRESH_SECRET', { infer: true }), 20 | }); 21 | } 22 | 23 | async validate(payload: jwtPayload): Promise { 24 | return { 25 | userId: payload.sub, 26 | email: payload.username, 27 | } as validatedJwtUserInfo; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/utils/auth/guards/local-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExecutionContext, 3 | Injectable, 4 | Logger, 5 | UnauthorizedException, 6 | } from '@nestjs/common'; 7 | import { AuthGuard } from '@nestjs/passport'; 8 | 9 | @Injectable() 10 | export class LocalAuthGuard extends AuthGuard('local') { 11 | private readonly logger = new Logger(LocalAuthGuard.name); 12 | 13 | async canActivate(context: ExecutionContext) { 14 | // Add your custom authentication logic here 15 | // for example, call super.logIn(request) to establish a session. 16 | // return super.canActivate(context); 17 | const result = (await super.canActivate(context)) as boolean; 18 | if (!result) { 19 | throw new UnauthorizedException('Authorization failed.'); 20 | } 21 | return result; 22 | } 23 | 24 | handleRequest(err, user, info, context, status) { 25 | // You can throw an exception based on either "info" or "err" arguments 26 | if (err || !user) { 27 | this.logger.error(info); 28 | throw err || new UnauthorizedException(); 29 | } 30 | return user; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/utils/auth/auth.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { JwtService } from '@nestjs/jwt'; 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | import { PrismaService } from '@utils/prisma/prisma.service'; 4 | import { UsersService } from '@api/users/users.service'; 5 | import { AuthController } from './auth.controller'; 6 | import { AuthService } from './auth.service'; 7 | import { BcryptService } from '@utils/auth/bcrypt'; 8 | import { ConfigModule } from '@nestjs/config'; 9 | 10 | describe('AuthController', () => { 11 | let controller: AuthController; 12 | 13 | beforeEach(async () => { 14 | const module: TestingModule = await Test.createTestingModule({ 15 | controllers: [AuthController], 16 | providers: [ 17 | AuthService, 18 | UsersService, 19 | PrismaService, 20 | JwtService, 21 | BcryptService, 22 | ], 23 | imports: [ConfigModule], 24 | }).compile(); 25 | 26 | controller = module.get(AuthController); 27 | }); 28 | 29 | it('should be defined', () => { 30 | expect(controller).toBeDefined(); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/api/org/org.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { CreateOrgDto } from './dto/create-org.dto'; 3 | import { UpdateOrgDto } from './dto/update-org.dto'; 4 | import { PrismaService } from '@utils/prisma/prisma.service'; 5 | import { Organization } from '@prisma/client'; 6 | 7 | @Injectable() 8 | export class OrgService { 9 | constructor(private readonly prismaService: PrismaService) {} 10 | 11 | async create(createOrgDto: CreateOrgDto): Promise { 12 | return this.prismaService.organization.create({ 13 | data: { domain: createOrgDto.domain, name: createOrgDto.name }, 14 | }); 15 | } 16 | 17 | findOne(id: string) { 18 | return this.prismaService.organization.findUnique({ where: { id: id } }); 19 | } 20 | 21 | update(id: string, updateOrgDto: UpdateOrgDto) { 22 | return this.prismaService.organization.update({ 23 | where: { id: id }, 24 | data: { domain: updateOrgDto.domain, name: updateOrgDto.name }, 25 | }); 26 | } 27 | 28 | remove(id: string) { 29 | return this.prismaService.organization.delete({ where: { id: id } }); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | db: 3 | # To mirror the image railway uses minus the postgis since there isn't arm compatability for it: https://docs.railway.app/databases/postgresql#image 4 | image: timescale/timescaledb:latest-pg15 5 | restart: always 6 | container_name: postgres 7 | hostname: postgres 8 | environment: 9 | PGUSER: postgres 10 | POSTGRES_PASSWORD: example 11 | volumes: 12 | - pgdata:/var/lib/postgresql/data 13 | healthcheck: 14 | test: ['CMD-SHELL', 'pg_isready'] 15 | interval: 1s 16 | timeout: 5s 17 | retries: 10 18 | ports: 19 | - 5432:5432 20 | 21 | adminer: 22 | image: adminer 23 | container_name: adminer 24 | restart: always 25 | ports: 26 | - 8080:8080 27 | depends_on: 28 | db: 29 | condition: service_healthy 30 | 31 | server: 32 | env_file: .docker.env 33 | container_name: server 34 | build: 35 | context: . 36 | dockerfile: Dockerfile 37 | ports: 38 | - '3000:3000' 39 | depends_on: 40 | db: 41 | condition: service_healthy 42 | 43 | volumes: 44 | pgdata: 45 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax = docker/dockerfile:1 2 | 3 | # Adjust NODE_VERSION as desired 4 | ARG NODE_VERSION=18.12.1 5 | FROM node:${NODE_VERSION}-slim as base 6 | 7 | 8 | LABEL fly_launch_runtime="NestJS" 9 | 10 | # NestJS app lives here 11 | WORKDIR /app 12 | 13 | # Set production environment 14 | ENV NODE_ENV=production 15 | 16 | 17 | # Throw-away build stage to reduce size of final image 18 | FROM base as build 19 | 20 | # Install packages needed to build node modules 21 | RUN apt-get update -qq && \ 22 | apt-get install -y python-is-python3 pkg-config build-essential 23 | 24 | # Install node modules 25 | COPY --link package-lock.json package.json ./ 26 | RUN npm ci --include=dev 27 | 28 | # Copy application code 29 | COPY --link . . 30 | 31 | # Generate prisma schema 32 | RUN npm run prisma:generate 33 | 34 | 35 | # Build application 36 | RUN npm run build 37 | 38 | 39 | # Run tests 40 | RUN npm run test 41 | 42 | # Final stage for app image 43 | FROM base 44 | 45 | # Copy built application 46 | COPY --from=build /app /app 47 | 48 | # Start the server by default, this can be overwritten at runtime 49 | EXPOSE 3000 50 | CMD [ "npm", "run", "start:migrate:prod" ] 51 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareConsumer, Module } from '@nestjs/common'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | 5 | import { UsersController } from '@api/users/users.controller'; 6 | import { UsersModule } from '@api/users/users.module'; 7 | import { ConfigModule } from '@nestjs/config'; 8 | import { AuthController } from '@utils/auth/auth.controller'; 9 | import { AuthModule } from '@utils/auth/auth.module'; 10 | import { configValidationSchema } from '@utils/config/config'; 11 | import { FastifyDomainFilterMiddleware } from '@utils/middleware/domain-filter.middleware'; 12 | import { PrismaService } from '@utils/prisma/prisma.service'; 13 | 14 | @Module({ 15 | imports: [ 16 | UsersModule, 17 | AuthModule, 18 | ConfigModule.forRoot({ 19 | isGlobal: true, 20 | cache: true, 21 | validationSchema: configValidationSchema, 22 | }), 23 | ], 24 | controllers: [AppController], 25 | providers: [AppService, PrismaService], 26 | }) 27 | export class AppModule { 28 | configure(consumer: MiddlewareConsumer) { 29 | consumer 30 | .apply(FastifyDomainFilterMiddleware) 31 | .forRoutes(AuthController, UsersController); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/utils/auth/guards/jwt-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExecutionContext, 3 | Injectable, 4 | UnauthorizedException, 5 | } from '@nestjs/common'; 6 | import { AuthGuard } from '@nestjs/passport'; 7 | import { Logger } from '@nestjs/common'; 8 | /** 9 | * Injects the user into the request object if successful: 10 | * `{request & {user: {userId: number, email:string}}}` 11 | * @link https://docs.nestjs.com/security/authentication#implementing-passport-jwt 12 | */ 13 | @Injectable() 14 | export class JwtAuthGuard extends AuthGuard('jwt') { 15 | private readonly logger = new Logger(JwtAuthGuard.name); 16 | async canActivate(context: ExecutionContext) { 17 | // Add your custom authentication logic here 18 | // for example, call super.logIn(request) to establish a session. 19 | // return super.canActivate(context); 20 | const result = (await super.canActivate(context)) as boolean; 21 | if (!result) { 22 | throw new UnauthorizedException('Authorization failed.'); 23 | } 24 | return result; 25 | } 26 | 27 | handleRequest(err, user, info, context, status) { 28 | // You can throw an exception based on either "info" or "err" arguments 29 | if (err || !user) { 30 | this.logger.error(info); 31 | throw err || new UnauthorizedException(); 32 | } 33 | return user; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/utils/auth/guards/refresh-token.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExecutionContext, 3 | Injectable, 4 | Logger, 5 | UnauthorizedException, 6 | } from '@nestjs/common'; 7 | import { AuthGuard } from '@nestjs/passport'; 8 | 9 | /** 10 | * Injects the user into the request object if successful: 11 | * `{request & {user: {userId: number, email:string}}}` 12 | * @link https://docs.nestjs.com/security/authentication#implementing-passport-jwt 13 | */ 14 | @Injectable() 15 | export class RefreshTokenGuard extends AuthGuard('jwt-refresh') { 16 | private readonly logger = new Logger(RefreshTokenGuard.name); 17 | 18 | canActivate(context: ExecutionContext) { 19 | // Add your custom authentication logic here 20 | // for example, call super.logIn(request) to establish a session. 21 | return super.canActivate(context); 22 | } 23 | 24 | handleRequest(err, user, info, context, status) { 25 | // You can throw an exception based on either "info" or "err" arguments 26 | if (err || !user) { 27 | this.logger.error(info); 28 | throw err || new UnauthorizedException(); 29 | } 30 | const authHeaders = context.switchToHttp().getRequest() 31 | .headers.authorization; 32 | const refreshToken: string = authHeaders.replace('Bearer', '').trim(); 33 | return { ...user, refreshToken: refreshToken }; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/utils/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { UsersModule } from '@api/users/users.module'; 2 | import { Module } from '@nestjs/common'; 3 | import { JwtModule } from '@nestjs/jwt'; 4 | import { PassportModule } from '@nestjs/passport'; 5 | 6 | import { BcryptService } from '@utils/auth/bcrypt'; 7 | import { RefreshTokenStrategy } from '@utils/auth/strategies/refreshToken.strategy'; 8 | 9 | import { ConfigModule, ConfigService } from '@nestjs/config'; 10 | import { ApiKeyService } from '@utils/api-key/api-key.service'; 11 | import { EnvironmentVariables } from '@utils/config/config'; 12 | import { PrismaService } from '@utils/prisma/prisma.service'; 13 | import { AuthController } from './auth.controller'; 14 | import { AuthService } from './auth.service'; 15 | import { JwtStrategy } from './strategies/jwt.strategy'; 16 | import { LocalStrategy } from './strategies/local.strategy'; 17 | 18 | @Module({ 19 | providers: [ 20 | AuthService, 21 | BcryptService, 22 | LocalStrategy, 23 | JwtStrategy, 24 | RefreshTokenStrategy, 25 | PrismaService, 26 | ApiKeyService, 27 | ], 28 | imports: [ 29 | UsersModule, 30 | PassportModule.register({ defaultStrategy: 'jwt' }), 31 | JwtModule.registerAsync({ 32 | imports: [ConfigModule], 33 | useFactory: async ( 34 | configService: ConfigService, 35 | ) => ({ 36 | secret: configService.get('JWT_ACCESS_SECRET', { infer: true }), 37 | signOptions: { expiresIn: '15m', algorithm: 'HS384' }, 38 | verifyOptions: { 39 | algorithms: ['HS384'], 40 | }, 41 | }), 42 | inject: [ConfigService], 43 | }), 44 | ], 45 | controllers: [AuthController], 46 | exports: [AuthService, ApiKeyService], 47 | }) 48 | export class AuthModule {} 49 | -------------------------------------------------------------------------------- /src/utils/api-key/guards/api-key-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CanActivate, 3 | ExecutionContext, 4 | HttpException, 5 | HttpStatus, 6 | Injectable, 7 | Logger, 8 | } from '@nestjs/common'; 9 | import { ApiKeyService } from '@utils/api-key/api-key.service'; 10 | import { AuthenticatedRequest } from '@utils/auth/types'; 11 | 12 | @Injectable() 13 | export class ApiKeyGuard implements CanActivate { 14 | constructor(private readonly apiKeyService: ApiKeyService) {} 15 | private readonly logger = new Logger(ApiKeyGuard.name); 16 | private sApiKeyBearer = 'x-api-key'; 17 | 18 | async canActivate(context: ExecutionContext): Promise { 19 | const req: AuthenticatedRequest = context.switchToHttp().getRequest(); 20 | const key: string = req.headers[this.sApiKeyBearer]; 21 | const reqInfoString = `${req.method}, url: ${req.url}`; 22 | if (!key) { 23 | this.logger.warn(`No API key provided. ${reqInfoString}`); 24 | throw new HttpException('No API key provided', HttpStatus.UNAUTHORIZED); 25 | } 26 | const keyObject = await this.apiKeyService.getKeyWithMetadata(key); 27 | if (!keyObject) { 28 | this.logger.warn( 29 | 'Invalid API key provided: ' + key + '. ' + reqInfoString, 30 | ); 31 | throw new HttpException( 32 | 'Invalid API key provided', 33 | HttpStatus.UNAUTHORIZED, 34 | ); 35 | } 36 | 37 | req.apiKey = keyObject; 38 | 39 | // Remove sensitive values from the request object 40 | delete req.apiKey.key; 41 | 42 | // TODO @allen: figure out best way to track these api requests (maybe in a table somewhere?) 43 | this.logger.log( 44 | `Valid API Request. method: ${req.method}, url: ${req.url}, user-id: ${req.apiKey.userId}, org-id: ${req.apiKey.organizationId}, key-id: ${req.apiKey.id}, key-name: ${req.apiKey.name}`, 45 | ); 46 | return true; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { AppModule } from '@app/app.module'; 2 | import { Logger, ValidationPipe, VersioningType } from '@nestjs/common'; 3 | import { ConfigService } from '@nestjs/config'; 4 | import { NestFactory } from '@nestjs/core'; 5 | import { 6 | FastifyAdapter, 7 | NestFastifyApplication, 8 | } from '@nestjs/platform-fastify'; 9 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; 10 | import { EnvironmentVariables } from '@utils/config/config'; 11 | 12 | import { sApiKeyBearer, sJwtBearer } from '@utils/header'; 13 | 14 | async function bootstrap() { 15 | const app = await NestFactory.create( 16 | AppModule, 17 | new FastifyAdapter({ logger: false }), 18 | ); 19 | app.useGlobalPipes(new ValidationPipe()); 20 | const logger = new Logger('NestBootstrap'); 21 | 22 | // Create a Swagger document options 23 | const options = new DocumentBuilder() 24 | .setTitle('Generic API') 25 | .setDescription('A generic API') 26 | .setVersion('1.0') 27 | .addBearerAuth( 28 | { 29 | type: 'http', 30 | scheme: 'bearer', 31 | bearerFormat: 'JWT', 32 | name: 'JWT', 33 | description: 'Enter JWT token', 34 | in: 'header', 35 | }, 36 | sJwtBearer, 37 | ) 38 | .addApiKey( 39 | { 40 | type: 'apiKey', 41 | scheme: 'apiKey', 42 | bearerFormat: 'apiKey', 43 | name: sApiKeyBearer, 44 | description: 'Enter API key', 45 | in: 'header', 46 | }, 47 | sApiKeyBearer, 48 | ) 49 | .build(); 50 | 51 | // Generate the Swagger document 52 | 53 | app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1' }); 54 | 55 | const document = SwaggerModule.createDocument(app, options); 56 | 57 | // Set up Swagger UI 58 | SwaggerModule.setup(`docs`, app, document); 59 | 60 | const configService = app.get(ConfigService); 61 | const PORT = configService.get('PORT', { infer: true }); 62 | 63 | await app.listen(PORT, '0.0.0.0'); // MUST specify 0.0.0.0 using fastify 64 | logger.log(`Listening on port ${PORT}`); 65 | } 66 | bootstrap(); 67 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | previewFeatures = ["postgresqlExtensions"] 7 | } 8 | 9 | datasource db { 10 | provider = "postgresql" 11 | url = env("DATABASE_URL") 12 | directUrl = env("DIRECT_URL") 13 | // If you want to use Prisma Migrate, you will need to manually create a shadow database 14 | // https://neon.tech/docs/guides/prisma-migrate#configure-a-shadow-database-for-prisma-migrate 15 | // make sure to append ?connect_timeout=10 to the connection string 16 | // shadowDatabaseUrl = env(“SHADOW_DATABASE_URL”) 17 | } 18 | 19 | // A user of the WattShift client app 20 | model User { 21 | id String @id @default(cuid()) 22 | createdAt DateTime @default(now()) 23 | updatedAt DateTime @updatedAt 24 | email String @unique 25 | emailVerified Boolean @default(false) 26 | passwordHash String 27 | username String? @unique 28 | name String? 29 | timezone String? 30 | refreshToken String? 31 | organization Organization @relation(fields: [organizationId], references: [id]) 32 | organizationId String 33 | apiKeys ApiKey[] 34 | PasswordReset PasswordReset[] 35 | } 36 | 37 | model PasswordReset { 38 | id String @id @default(cuid()) 39 | createdAt DateTime @default(now()) 40 | updatedAt DateTime @updatedAt 41 | userId String 42 | user User @relation(fields: [userId], references: [id]) 43 | token String @unique 44 | } 45 | 46 | model Organization { 47 | id String @id @default(cuid()) 48 | createdAt DateTime @default(now()) 49 | updatedAt DateTime @updatedAt 50 | domain String 51 | name String? 52 | users User[] 53 | apiKeys ApiKey[] 54 | } 55 | 56 | model ApiKey { 57 | id String @id @default(cuid()) 58 | createdAt DateTime @default(now()) 59 | updatedAt DateTime @updatedAt 60 | userId String 61 | organizationId String 62 | user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) 63 | organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade) 64 | name String 65 | key String @unique 66 | scopes String[] // The scopes that the api key has access to 67 | } 68 | -------------------------------------------------------------------------------- /prisma/migrations/20230927190631_db_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "User" ( 3 | "id" TEXT NOT NULL, 4 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 5 | "updatedAt" TIMESTAMP(3) NOT NULL, 6 | "email" TEXT NOT NULL, 7 | "emailVerified" BOOLEAN NOT NULL DEFAULT false, 8 | "passwordHash" TEXT NOT NULL, 9 | "username" TEXT, 10 | "name" TEXT, 11 | "timezone" TEXT, 12 | "refreshToken" TEXT, 13 | "organizationId" TEXT NOT NULL, 14 | 15 | CONSTRAINT "User_pkey" PRIMARY KEY ("id") 16 | ); 17 | 18 | -- CreateTable 19 | CREATE TABLE "PasswordReset" ( 20 | "id" TEXT NOT NULL, 21 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 22 | "updatedAt" TIMESTAMP(3) NOT NULL, 23 | "userId" TEXT NOT NULL, 24 | "token" TEXT NOT NULL, 25 | 26 | CONSTRAINT "PasswordReset_pkey" PRIMARY KEY ("id") 27 | ); 28 | 29 | -- CreateTable 30 | CREATE TABLE "Organization" ( 31 | "id" TEXT NOT NULL, 32 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 33 | "updatedAt" TIMESTAMP(3) NOT NULL, 34 | "domain" TEXT NOT NULL, 35 | "name" TEXT, 36 | 37 | CONSTRAINT "Organization_pkey" PRIMARY KEY ("id") 38 | ); 39 | 40 | -- CreateTable 41 | CREATE TABLE "ApiKey" ( 42 | "id" TEXT NOT NULL, 43 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 44 | "updatedAt" TIMESTAMP(3) NOT NULL, 45 | "userId" TEXT NOT NULL, 46 | "organizationId" TEXT NOT NULL, 47 | "name" TEXT NOT NULL, 48 | "key" TEXT NOT NULL, 49 | "scopes" TEXT[], 50 | 51 | CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id") 52 | ); 53 | 54 | -- CreateIndex 55 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); 56 | 57 | -- CreateIndex 58 | CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); 59 | 60 | -- CreateIndex 61 | CREATE UNIQUE INDEX "PasswordReset_token_key" ON "PasswordReset"("token"); 62 | 63 | -- CreateIndex 64 | CREATE UNIQUE INDEX "ApiKey_key_key" ON "ApiKey"("key"); 65 | 66 | -- AddForeignKey 67 | ALTER TABLE "User" ADD CONSTRAINT "User_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 68 | 69 | -- AddForeignKey 70 | ALTER TABLE "PasswordReset" ADD CONSTRAINT "PasswordReset_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 71 | 72 | -- AddForeignKey 73 | ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 74 | 75 | -- AddForeignKey 76 | ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; 77 | -------------------------------------------------------------------------------- /src/api/users/users.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | HttpException, 7 | HttpStatus, 8 | Param, 9 | Patch, 10 | Post, 11 | Request, 12 | UseGuards, 13 | } from '@nestjs/common'; 14 | import { ApiExcludeController, ApiTags } from '@nestjs/swagger'; 15 | import { UpdateUserDto } from './dto/update-user.dto'; 16 | import { UsersService } from './users.service'; 17 | 18 | import { ApiKeyService } from '@utils/api-key/api-key.service'; 19 | import { JwtAuthGuard } from '@utils/auth/guards/jwt-auth.guard'; 20 | import { JwtAuthenticatedRequest } from '@utils/auth/types'; 21 | import { JwtBearer, SHOW_CONTROLLER_IN_SWAGGER } from '@utils/header'; 22 | 23 | const CONTROLLER_NAME = `user`; 24 | @ApiTags(CONTROLLER_NAME) 25 | @JwtBearer 26 | @ApiExcludeController(SHOW_CONTROLLER_IN_SWAGGER) 27 | @UseGuards(JwtAuthGuard) 28 | @Controller(CONTROLLER_NAME) 29 | export class UsersController { 30 | constructor( 31 | private readonly usersService: UsersService, 32 | private readonly apiKeyService: ApiKeyService, 33 | ) {} 34 | 35 | @Get() 36 | async findOne(@Request() req: JwtAuthenticatedRequest) { 37 | const user = await this.usersService.findOne(req.user.userId); 38 | if (!user) { 39 | throw new HttpException(`Error: No user found `, HttpStatus.NOT_FOUND); 40 | } 41 | return user; 42 | } 43 | 44 | @Patch() 45 | update( 46 | @Request() req: JwtAuthenticatedRequest, 47 | @Body() updateUserDto: UpdateUserDto, 48 | ) { 49 | if (!req.user.userId) { 50 | throw new HttpException(`Error: No user found `, HttpStatus.NOT_FOUND); 51 | } 52 | return this.usersService.update(req.user.userId, updateUserDto); 53 | } 54 | 55 | @Delete() 56 | remove(@Request() req: JwtAuthenticatedRequest) { 57 | if (!req.user.userId) { 58 | throw new HttpException(`Error: No user found `, HttpStatus.NOT_FOUND); 59 | } 60 | return this.usersService.remove(req.user.userId); 61 | } 62 | 63 | @Get('api-keys') 64 | async getApiKeys(@Request() req: JwtAuthenticatedRequest) { 65 | return this.apiKeyService.findKeyByOwner(req.user.userId); 66 | } 67 | 68 | @Post('api-key') 69 | async createApiKey(@Request() req: JwtAuthenticatedRequest) { 70 | const user = await this.usersService.findOne(req.user.userId); 71 | if (!user) { 72 | throw new HttpException(`Error: No user found `, HttpStatus.NOT_FOUND); 73 | } 74 | return this.apiKeyService.createKey({ 75 | userId: user.id, 76 | organizationId: user.organizationId, 77 | }); 78 | } 79 | 80 | @Delete('api-key/:id') 81 | async deleteApiKey( 82 | @Param('id') id: string, 83 | @Request() req: JwtAuthenticatedRequest, 84 | ) { 85 | const user = await this.usersService.findOne(req.user.userId); 86 | if (!user) { 87 | throw new HttpException(`Error: No user found `, HttpStatus.NOT_FOUND); 88 | } 89 | return this.apiKeyService.deleteKey(id, user.id, user.organizationId); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/utils/api-key/api-key.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Prisma } from '@prisma/client'; 3 | import { CreateApiKeyResponseDto } from '@utils/api-key/dto/create-api-key-success.dto'; 4 | import { CreateApiKeyDto } from '@utils/api-key/dto/create-api-key.dto'; 5 | import { randomBytesAsync } from '@utils/crypto'; 6 | import { PrismaService } from '@utils/prisma/prisma.service'; 7 | 8 | @Injectable() 9 | export class ApiKeyService { 10 | constructor(private readonly prismaService: PrismaService) {} 11 | 12 | async createKey( 13 | createApiKeyDto: CreateApiKeyDto, 14 | ): Promise { 15 | const buffer = await randomBytesAsync(32); 16 | const keyHexString = `sk-${buffer.toString('hex')}`; 17 | const existingKeys = await this.findKeyByOwner(createApiKeyDto.userId); 18 | const result = await this.prismaService.apiKey.create({ 19 | data: { 20 | user: { connect: { id: createApiKeyDto.userId } }, 21 | organization: { connect: { id: createApiKeyDto.organizationId } }, 22 | key: keyHexString, 23 | name: `Personal Key - ${existingKeys.length + 1}`, 24 | }, 25 | include: { 26 | user: true, 27 | }, 28 | }); 29 | if (!result) { 30 | return { 31 | apiKey: null, 32 | success: false, 33 | message: 'API key creation failed. Please try again.', 34 | ownerEmail: result.user.email, 35 | }; 36 | } 37 | return { 38 | apiKey: result.key, 39 | success: true, 40 | message: 41 | "API key created successfully. Please copy the key in the `apiKey` field and store it in a safe place, as you won't be able to retrieve it again. ", 42 | ownerEmail: result.user.email, 43 | }; 44 | } 45 | 46 | async getApiKey(key: string, includes?: Prisma.ApiKeyInclude) { 47 | const input: Prisma.ApiKeyWhereUniqueInput = { key: key }; 48 | if (includes) { 49 | return this.prismaService.apiKey.findUnique({ 50 | where: input, 51 | include: includes, 52 | }); 53 | } 54 | return this.prismaService.apiKey.findUnique({ 55 | where: { key: key }, 56 | }); 57 | } 58 | 59 | /** 60 | * Get the key, along with the user and organization metadata 61 | * @param key The api key's value 62 | * @returns 63 | */ 64 | async getKeyWithMetadata(key: string) { 65 | return this.getApiKey(key, { organization: true, user: true }); 66 | } 67 | 68 | async isKeyValid(key: string): Promise { 69 | const result = await this.getApiKey(key); 70 | return !!result; 71 | } 72 | 73 | async findKeyByOwner(ownerId: string) { 74 | return this.prismaService.apiKey.findMany({ 75 | where: { userId: ownerId }, 76 | }); 77 | } 78 | 79 | async deleteKey(key: string, ownerId: string, orgId: string) { 80 | const result = await this.prismaService.apiKey.delete({ 81 | where: { key: key, userId: ownerId, organizationId: orgId }, 82 | }); 83 | return result; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/utils/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { CreateUserDto } from '@api/users/dto/create-user.dto'; 2 | import { 3 | Body, 4 | Controller, 5 | Get, 6 | Post, 7 | Request, 8 | UseGuards, 9 | } from '@nestjs/common'; 10 | import { 11 | ApiBearerAuth, 12 | ApiBody, 13 | ApiExcludeController, 14 | ApiTags, 15 | } from '@nestjs/swagger'; 16 | import { JwtAuthGuard } from '@utils/auth/guards/jwt-auth.guard'; 17 | import { RefreshTokenGuard } from '@utils/auth/guards/refresh-token.guard'; 18 | import { 19 | JwtAuthenticatedRequest, 20 | PasswordAuthenticatedRequest, 21 | } from '@utils/auth/types'; 22 | import { 23 | SHOW_CONTROLLER_IN_SWAGGER, 24 | JwtBearer, 25 | JwtHeader, 26 | sJwtBearer, 27 | } from '../header'; 28 | import { AuthService } from './auth.service'; 29 | import { LocalAuthGuard } from './guards/local-auth.guard'; 30 | import { 31 | CheckPasswordResetDto, 32 | PasswordResetDto, 33 | } from '@utils/auth/dto/password-reset.dto'; 34 | import { UsersService } from '@api/users/users.service'; 35 | import { Cron, CronExpression } from '@nestjs/schedule'; 36 | 37 | const CONTROLLER_NAME = `auth`; 38 | @ApiTags(CONTROLLER_NAME) 39 | @ApiExcludeController(SHOW_CONTROLLER_IN_SWAGGER) 40 | @JwtBearer 41 | @Controller(CONTROLLER_NAME) 42 | export class AuthController { 43 | constructor( 44 | private readonly authService: AuthService, 45 | private readonly userService: UsersService, 46 | ) {} 47 | 48 | @Post('signup') 49 | async create(@Body() createUserDto: CreateUserDto) { 50 | return this.authService.signup(createUserDto); 51 | } 52 | 53 | @Post('login') 54 | @UseGuards(LocalAuthGuard) 55 | @ApiBody({ 56 | schema: { 57 | type: 'object', 58 | properties: { 59 | email: { type: 'string' }, 60 | password: { type: 'string' }, 61 | }, 62 | }, 63 | }) 64 | async login(@Request() req: PasswordAuthenticatedRequest) { 65 | return this.authService.login(req.user); 66 | } 67 | 68 | @Get('logout') 69 | @JwtHeader 70 | @ApiBearerAuth(sJwtBearer) 71 | @UseGuards(JwtAuthGuard) 72 | async logout(@Request() req: JwtAuthenticatedRequest) { 73 | return this.authService.logout(req.user); 74 | } 75 | 76 | @Get('refresh') 77 | @ApiBearerAuth(sJwtBearer) 78 | @UseGuards(RefreshTokenGuard) 79 | async refresh(@Request() req: JwtAuthenticatedRequest) { 80 | return this.authService.refreshTokens( 81 | req.user.userId, 82 | req.user.refreshToken, 83 | ); 84 | } 85 | 86 | @Post('password-reset/start') 87 | async passwordReset(@Body() data: PasswordResetDto) { 88 | return this.authService.sendPasswordResetEmail(data.email); 89 | } 90 | 91 | @Post('password-reset/check') 92 | async checkPasswordReset(@Body() data: CheckPasswordResetDto) { 93 | return this.authService.checkPasswordResetToken( 94 | data.email, 95 | data.resetToken, 96 | ); 97 | } 98 | 99 | @Cron(CronExpression.EVERY_30_MINUTES, { name: 'deleteOldResets' }) 100 | @Get('password-reset/delete-old') 101 | async deleteOldResets() { 102 | const startTime = new Date(new Date().getTime() - 600001); 103 | return this.userService.deletePasswordResetsOlderThan(startTime); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wattshift-api", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "allen-n", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "build": "nest build", 10 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 11 | "start": "nest start", 12 | "start:dev": "nest start --watch", 13 | "start:debug": "nest start --debug --watch", 14 | "start:prod": "node dist/main", 15 | "prisma:generate": "prisma generate", 16 | "prisma:debug": "prisma db push", 17 | "prisma:dev": "prisma migrate dev --name", 18 | "prisma:deploy": "prisma migrate deploy", 19 | "start:migrate:prod": "npm run prisma:deploy && npm run start:prod", 20 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 21 | "test": "jest", 22 | "test:watch": "jest --watch", 23 | "test:cov": "jest --coverage", 24 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 25 | "test:e2e": "jest --config ./test/jest-e2e.json", 26 | "prettier": "prettier --write \"./**/*.{js,jsx,ts,tsx,json}\"" 27 | }, 28 | "dependencies": { 29 | "@fastify/static": "^6.10.2", 30 | "@nestjs/common": "^10.0.0", 31 | "@nestjs/config": "^3.0.0", 32 | "@nestjs/core": "^10.0.0", 33 | "@nestjs/jwt": "^10.1.0", 34 | "@nestjs/passport": "^10.0.0", 35 | "@nestjs/platform-express": "^10.0.0", 36 | "@nestjs/platform-fastify": "^10.1.0", 37 | "@nestjs/schedule": "^3.0.3", 38 | "@nestjs/swagger": "^7.1.1", 39 | "@prisma/client": "^5.1.1", 40 | "bcrypt": "^5.1.0", 41 | "class-transformer": "^0.5.1", 42 | "class-validator": "^0.14.0", 43 | "dotenv": "^16.3.1", 44 | "joi": "^17.10.0", 45 | "passport": "^0.6.0", 46 | "passport-jwt": "^4.0.1", 47 | "passport-local": "^1.0.0", 48 | "reflect-metadata": "^0.1.13", 49 | "resend": "^1.0.0", 50 | "rxjs": "^7.8.1" 51 | }, 52 | "devDependencies": { 53 | "@nestjs/cli": "^10.1.16", 54 | "@nestjs/testing": "^10.2.1", 55 | "@swc/cli": "^0.1.62", 56 | "@swc/core": "^1.3.70", 57 | "@types/bcrypt": "^5.0.0", 58 | "@types/jest": "^29.5.3", 59 | "@types/node": "^20.3.1", 60 | "@types/passport-jwt": "^3.0.9", 61 | "@types/passport-local": "^1.0.35", 62 | "@types/supertest": "^2.0.12", 63 | "@typescript-eslint/eslint-plugin": "^5.59.11", 64 | "@typescript-eslint/parser": "^5.59.11", 65 | "eslint": "^8.42.0", 66 | "eslint-config-prettier": "^8.8.0", 67 | "eslint-plugin-prettier": "^4.2.1", 68 | "jest": "^29.5.0", 69 | "prettier": "^2.8.8", 70 | "supertest": "^6.3.3", 71 | "ts-jest": "^29.1.0", 72 | "ts-node": "^10.9.1", 73 | "typescript": "^5.1.3" 74 | }, 75 | "jest": { 76 | "moduleFileExtensions": [ 77 | "js", 78 | "json", 79 | "ts" 80 | ], 81 | "rootDir": "src", 82 | "testRegex": ".*\\.spec\\.ts$", 83 | "transform": { 84 | "^.+\\.(t|j)s$": "ts-jest" 85 | }, 86 | "collectCoverageFrom": [ 87 | "**/*.(t|j)s" 88 | ], 89 | "coverageDirectory": "../coverage", 90 | "testEnvironment": "node", 91 | "moduleNameMapper": { 92 | "^@api(.*)$": "/api/$1", 93 | "^@client(.*)$": "/client/$1", 94 | "^@utils(.*)$": "/utils/$1", 95 | "^@app(.*)$": "/app/$1" 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/api/users/users.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; 2 | import { Prisma } from '@prisma/client'; 3 | import { PrismaService } from '@utils/prisma/prisma.service'; 4 | 5 | import { BcryptService } from '@utils/auth/bcrypt'; 6 | import { CreateUserDto } from './dto/create-user.dto'; 7 | import { UpdateUserDto } from './dto/update-user.dto'; 8 | 9 | @Injectable() 10 | export class UsersService { 11 | constructor( 12 | private readonly prismaService: PrismaService, 13 | private readonly bcryptService: BcryptService, 14 | ) {} 15 | 16 | async create(data: CreateUserDto) { 17 | // For now, create a personal org for each user 18 | const existingUser = await this.findByEmail(data.email); 19 | if (existingUser) { 20 | throw new HttpException( 21 | `Error: A user with email ${data.email} already exists`, 22 | HttpStatus.UNPROCESSABLE_ENTITY, 23 | ); 24 | } 25 | 26 | const passwordHash = await this.bcryptService.hash(data.password); 27 | const refreshTokenHash = !!data.refreshToken 28 | ? await this.bcryptService.hash(data.refreshToken) 29 | : null; 30 | const readableName = data.name || data.username || data.email; 31 | return this.prismaService.user.create({ 32 | data: { 33 | email: data.email, 34 | passwordHash: passwordHash, 35 | username: data.username, 36 | name: data.name, 37 | timezone: data.timezone, 38 | refreshToken: refreshTokenHash, 39 | organization: { 40 | create: { 41 | name: `${readableName}'s Personal Organization`, 42 | domain: `${readableName}.personal`, 43 | }, 44 | }, 45 | }, 46 | }); 47 | } 48 | 49 | async findOne(id: string) { 50 | return this.prismaService.user.findUnique({ 51 | where: { id: id }, 52 | }); 53 | } 54 | 55 | async findUser(id: string) { 56 | return this.prismaService.user.findUnique({ 57 | where: { id: id }, 58 | }); 59 | } 60 | 61 | async findByEmail(email: string) { 62 | return this.prismaService.user.findUnique({ 63 | where: { email: email }, 64 | }); 65 | } 66 | 67 | async update(id: string, updateUserDto: UpdateUserDto) { 68 | const passwordHash = updateUserDto.password 69 | ? await this.bcryptService.hash(updateUserDto.password) 70 | : null; 71 | 72 | delete updateUserDto.password; 73 | const data: Prisma.UserUpdateInput = { 74 | ...updateUserDto, 75 | }; 76 | if (passwordHash) { 77 | data.passwordHash = passwordHash; 78 | } 79 | return this.prismaService.user.update({ 80 | where: { id: id }, 81 | data: data, 82 | }); 83 | } 84 | 85 | async remove(id: string) { 86 | return this.prismaService.user.delete({ where: { id: id } }); 87 | } 88 | 89 | async deleteAllPAsswordResetsByUserId(userId: string) { 90 | return this.prismaService.passwordReset.deleteMany({ 91 | where: { userId: userId }, 92 | }); 93 | } 94 | 95 | async createPasswordReset(userId: string, token: string) { 96 | return this.prismaService.passwordReset.create({ 97 | data: { 98 | user: { 99 | connect: { 100 | id: userId, 101 | }, 102 | }, 103 | token: token, 104 | }, 105 | }); 106 | } 107 | 108 | async findPasswordReset(userId: string, token: string) { 109 | return this.prismaService.passwordReset.findUnique({ 110 | where: { token: token, userId: userId }, 111 | }); 112 | } 113 | 114 | async deletePasswordResetsOlderThan(date: Date) { 115 | return this.prismaService.passwordReset.deleteMany({ 116 | where: { 117 | createdAt: { 118 | lt: date, 119 | }, 120 | }, 121 | }); 122 | } 123 | 124 | async deletePasswordReset(id: string) { 125 | return this.prismaService.passwordReset.delete({ 126 | where: { id: id }, 127 | }); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Generic NestJs Backend 2 | 3 | This is a generic nestjs backend that can be used to create a backend for any project. It can be deployed anywhere that you can deploy a docker container, and hooked up to any postgres database provider. It also includes instructions and configuration for easy local debugging. You can 1-click deploy on railway using the link below (you'll need to create a railway account first). 4 | 5 | [![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/5eHaqw?referralCode=gPQRvt) 6 | 7 | ## Features 8 | 9 | * User authentication (using JWTs, refresh tokens, etc.) 10 | * Creating users and organizations (i.e. multi tenant support) 11 | * Creating and issuing API keys with attached scopes 12 | * Transactional email using resend 13 | 14 | ## Tools used 15 | 16 | * Nest.js as the server framework (using fastify under the hood) 17 | * prisma + postgres as the database 18 | * docker for local development 19 | * passport.js for authentication 20 | * jest for testing 21 | * eslint for linting 22 | * prettier for formatting 23 | 24 | ## Contributing 25 | 26 | 1. Branch all PRs from the `main` branch using the branch name from Linear (cmd+shift+. on mac). 27 | 2. Do your work. 28 | 3. Push upstream and open a PR to merge into `main`. 29 | 4. Once the PR is approved, merge it into `main`. This will push the changes to the prod environment. 30 | 31 | ## How to work on the API locally 32 | 33 | If you're using vscode, you can use the `Start Debug Server` config, which will: 34 | 35 | 1. spin up the database and adminer containers 36 | 2. Start the API in dev mode (i.e. hot reload) 37 | 3. Spin down the containers when you kill the debugger 38 | 39 | Otherwise, just run `docker compose up db adminer` to spin up the database and adminer containers, and then run `npm run start:dev` to start the API in dev mode (and `docker compose down` to spin down the containers when you're done). 40 | 41 | ## Running the local postgres docker container 42 | 43 | These instructions are [cribbed from here](https://www.docker.com/blog/how-to-use-the-postgres-docker-official-image). 44 | 45 | 1. Download the latest [docker desktop release](https://www.docker.com/products/docker-desktop/). 46 | 2. Run the following command to pull the latest images and then start the container: 47 | 48 | ```bash 49 | docker compose up db 50 | ``` 51 | 52 | To build everything, omit `db`, which will spin up the server and adminer db UI, all in the docker container. To include adminer but not turn on the server in the container, add `adminer` (i.e. `docker compose up db adminer`). 53 | 54 | If you need to rebuild a container, run the following command: 55 | 56 | ```bash 57 | docker compose up --build # omit container name to rebuild all 58 | ``` 59 | 60 | To remove old containers: 61 | 62 | ```bash 63 | docker compose down # removes all containers 64 | docker compose down --volumes # Remove containers and volumes 65 | ``` 66 | 67 | ## Updating the prisma database schema 68 | 69 | See [best practices from prisma here](https://www.prisma.io/docs/guides/migrate/prototyping-schema-db-push). 70 | 71 | Overview: 72 | 73 | 1. Make changes to the schema in `prisma/schema.prisma`. 74 | 2. Start the local database container (see above). 75 | 3. run `npm run prisma:debug` to test the change on the local database (or run `npx prisma db push`). 76 | 4. Keep making changes until you're happy with them, using the `prisma:debug` command to test them. 77 | 5. Once you're happy with them, you can `git stash` to stash the changes you made to the schema and run `prisma:debug` once more go go back to what you had. Then `git stash pop` to get the changes back (which you know work, due to your prototyping in steps 1-4). 78 | 6. Finally, run `npm run prisma:dev ` (or `npx prisma migrate --name `) create a migration that creates the changes. You can skip step 5, but that will create drift in the database that will force you to reset it completely. 79 | 80 | ## How to run the server outside the container 81 | 82 | ### Description 83 | 84 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. 85 | 86 | ### Installation 87 | 88 | ```bash 89 | npm install 90 | ``` 91 | 92 | ### Running the app 93 | 94 | ```bash 95 | # development 96 | $ npm run start 97 | 98 | # watch mode 99 | $ npm run start:dev 100 | 101 | # production mode 102 | $ npm run start:prod 103 | ``` 104 | 105 | ### Test 106 | 107 | ```bash 108 | # unit tests 109 | $ npm run test 110 | 111 | # e2e tests 112 | $ npm run test:e2e 113 | 114 | # test coverage 115 | $ npm run test:cov 116 | ``` 117 | 118 | ### Authors 119 | 120 | * [Allen](https://github.com/allen-n) 121 | -------------------------------------------------------------------------------- /src/utils/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { CreateUserDto } from '@api/users/dto/create-user.dto'; 2 | import { UsersService } from '@api/users/users.service'; 3 | import { 4 | ForbiddenException, 5 | Injectable, 6 | Logger, 7 | UnauthorizedException, 8 | } from '@nestjs/common'; 9 | import { ConfigService } from '@nestjs/config'; 10 | import { JwtService } from '@nestjs/jwt'; 11 | import { User } from '@prisma/client'; 12 | import { BcryptService } from '@utils/auth/bcrypt'; 13 | import { EnvironmentVariables } from '@utils/config/config'; 14 | import { randomBytesAsync } from '@utils/crypto'; 15 | import { randomBytes } from 'crypto'; 16 | import { validatedJwtUserInfo } from './types'; 17 | 18 | @Injectable() 19 | export class AuthService { 20 | private logger = new Logger(AuthService.name); 21 | constructor( 22 | private readonly usersService: UsersService, 23 | private readonly jwtService: JwtService, 24 | private readonly bcryptService: BcryptService, 25 | private readonly configService: ConfigService, 26 | ) {} 27 | 28 | async signup(data: CreateUserDto) { 29 | const user = await this.usersService.create(data); 30 | 31 | const tokens = await this.getTokens(user.id, user.email); 32 | await this.updateRefreshToken(user.id, tokens.refreshToken); 33 | const updatedUser = await this.usersService.update(user.id, { 34 | refreshToken: await this.bcryptService.hash(tokens.refreshToken), 35 | }); 36 | this.logger.log(`Created user with email: ${user.email}`); 37 | return { user: updatedUser, tokens: tokens }; 38 | } 39 | 40 | async validateUser( 41 | email: string, 42 | plaintextPass: string, 43 | ): Promise { 44 | const user = await this.usersService.findByEmail(email); 45 | if (!user) { 46 | throw new UnauthorizedException( 47 | `Error: No user found with email ${email}`, 48 | ); 49 | } 50 | const validPassword = await this.bcryptService.compare( 51 | plaintextPass, 52 | user.passwordHash, 53 | ); 54 | if (!validPassword) { 55 | return null; 56 | } 57 | 58 | delete user.passwordHash; // Remove the hashed password 59 | return user; 60 | } 61 | 62 | /** 63 | * logs the given user in and returns a JWT token for them 64 | * @param user 65 | * @returns 66 | */ 67 | async login(user: User) { 68 | const tokens = await this.getTokens(user.id, user.email); 69 | return { 70 | username: user.email, 71 | ...tokens, 72 | }; 73 | } 74 | 75 | async logout(user: validatedJwtUserInfo) { 76 | await this.usersService.update(user.userId, { refreshToken: null }); 77 | return { message: 'Logged out successfully' }; 78 | } 79 | 80 | async updateRefreshToken(userId: string, refreshToken: string) { 81 | const hashedRefreshToken = await this.bcryptService.hash(refreshToken); 82 | await this.usersService.update(userId, { 83 | refreshToken: hashedRefreshToken, 84 | }); 85 | } 86 | 87 | async getTokens( 88 | userId: string, 89 | email: string, 90 | ): Promise<{ refreshToken: string; accessToken: string }> { 91 | const [accessToken, refreshToken] = await Promise.all([ 92 | this.jwtService.signAsync( 93 | { 94 | sub: userId, 95 | username: email, 96 | }, 97 | { 98 | expiresIn: '15m', 99 | secret: this.configService.get('JWT_ACCESS_SECRET', { infer: true }), 100 | }, 101 | ), 102 | this.jwtService.signAsync( 103 | { 104 | sub: userId, 105 | username: email, 106 | }, 107 | { 108 | secret: this.configService.get('JWT_REFRESH_SECRET', { infer: true }), 109 | expiresIn: '7d', 110 | }, 111 | ), 112 | ]); 113 | 114 | return { 115 | accessToken: accessToken, 116 | refreshToken: refreshToken, 117 | }; 118 | } 119 | 120 | async refreshTokens(userId: string, refreshToken: string) { 121 | const user = await this.usersService.findUser(userId); 122 | if (!user) { 123 | throw new ForbiddenException('Access Denied: User not found'); 124 | } 125 | if (!user.refreshToken) { 126 | throw new ForbiddenException( 127 | 'Access Denied: User does not have a refresh token', 128 | ); 129 | } 130 | 131 | const refreshTokenMatches = await this.bcryptService.compare( 132 | refreshToken, 133 | user.refreshToken, 134 | ); 135 | if (!refreshTokenMatches) { 136 | throw new ForbiddenException('Access Denied: Bad refresh token'); 137 | } 138 | const tokens = await this.getTokens(user.id, user.email); 139 | await this.updateRefreshToken(user.id, tokens.refreshToken); 140 | return tokens; 141 | } 142 | 143 | async sendPasswordResetEmail(email: string) { 144 | const user = await this.usersService.findByEmail(email); 145 | if (!user) { 146 | throw new ForbiddenException('Access Denied: User not found'); 147 | } 148 | const buffer = await randomBytesAsync(20); 149 | const token = buffer.toString('hex'); 150 | await this.usersService.deleteAllPAsswordResetsByUserId(user.id); 151 | 152 | const reset = await this.usersService.createPasswordReset(user.id, token); 153 | this.logger.log( 154 | `Sending password reset email to ${email} with magic id ${reset.token}`, 155 | ); 156 | 157 | // Note: You can use the EmailService to send emails here if desired, though the url will depend on your frontend implementation 158 | return { message: `Password reset email sent to ${email}` }; 159 | } 160 | 161 | async checkPasswordResetToken(email: string, token: string) { 162 | const user = await this.usersService.findByEmail(email); 163 | 164 | if (!user) { 165 | throw new ForbiddenException('Access Denied: User not found'); 166 | } 167 | const reset = await this.usersService.findPasswordReset(user.id, token); 168 | if (!reset) { 169 | throw new ForbiddenException( 170 | 'Access Denied: Email mismatch with reset token', 171 | ); 172 | } 173 | // There is a valid password reset token, so delete it 174 | await this.usersService.deleteAllPAsswordResetsByUserId(user.id); 175 | const isOutdated = 176 | new Date().getTime() - reset.createdAt.getTime() > 600000; // 10 minutes in milliseconds 177 | if (isOutdated) { 178 | throw new ForbiddenException( 179 | 'Access Denied: Password reset token expired. Reset code deleted, please start over.', 180 | ); 181 | } 182 | const tokens = await this.getTokens(user.id, user.email); 183 | return { 184 | email: user.email, 185 | message: 'Password reset token is valid', 186 | tokens: tokens, 187 | }; 188 | } 189 | } 190 | --------------------------------------------------------------------------------