Set up PostgreSQL database configuration and enhance application structure

- Added .env.example with database connection details and JWT configuration.
- Introduced docker-compose.yml for PostgreSQL service setup with health checks.
- Created drizzle.config.ts for database schema and migration management.
- Updated nest-cli.json to include Swagger plugin configuration for API documentation.
- Enhanced package.json with new database-related scripts and dependencies.
- Implemented initial database migrations for user and token management.
- Configured application bootstrap process to load environment variables and set up Swagger.
- Added shared application configuration in configure-app.ts for consistent setup.
- Included unit tests for application configuration and Swagger setup.
This commit is contained in:
shancheas
2026-08-21 15:00:19 +07:00
parent 7f37985d22
commit d01fd6e2ef
54 changed files with 4378 additions and 87 deletions
+56
View File
@@ -0,0 +1,56 @@
---
description: NestJS OpenAPI/Swagger conventions for controllers and DTOs
globs: "src/**/*.controller.ts,src/**/dto/**/*.ts,src/common/swagger/**/*.ts,src/main.ts,nest-cli.json"
alwaysApply: false
---
# NestJS Swagger Best Practices
## Controllers
Document every HTTP handler:
```typescript
@ApiTags('auth')
@Controller('auth')
export class AuthController {
@Post('login')
@ApiOperation({ summary: 'Log in' })
@ApiOkResponse({ type: TokenPairDto })
login(@Body() dto: LoginDto) { /* ... */ }
@Get('me')
@ApiBearerAuth('access-token')
@ApiOkResponse({ type: MeResponseDto })
me(@CurrentUser() user: AuthUser) { /* ... */ }
}
```
- Use `@ApiTags`, `@ApiOperation`, and typed `@ApiOkResponse` / `@ApiCreatedResponse` / `@ApiNoContentResponse`
- Response types must be DTO **classes**, not interfaces or domain entities
- Add `@ApiBearerAuth('access-token')` only on JWT-protected routes
- Public (`@Public()`) routes must omit bearer auth
- Document expected errors (`@ApiBadRequestResponse`, `@ApiUnauthorizedResponse`, etc.) when meaningful
## DTOs
- Request and response DTOs live under `dto/`
- Never expose domain entities or sensitive fields (`passwordHash`, secrets) in OpenAPI
- Nest CLI Swagger plugin is enabled — use `@ApiProperty` for examples, `format: 'password'`, `writeOnly`, not to restate every class-validator rule
- Examples must be fake; never real tokens or secrets
```typescript
// BAD — documents entity / real secret
@ApiOkResponse({ type: User })
@ApiProperty({ example: process.env.JWT_ACCESS_SECRET })
// GOOD — response DTO + fake example
@ApiOkResponse({ type: MeResponseDto })
@ApiProperty({ example: 'alice', format: 'password', writeOnly: true })
```
## Bootstrap
- Mount UI at `/docs` and JSON at `/docs-json` only (via `setupSwagger` / `configureApp`)
- Swagger is off when `NODE_ENV=production` unless `SWAGGER_ENABLED=true`
- Keep the `@nestjs/swagger` plugin in `nest-cli.json` (`classValidatorShim`, `introspectComments`, `dtoFileNameSuffix: [".dto.ts"]`)
+1
View File
@@ -13,6 +13,7 @@ Stack: NestJS + PostgreSQL + Drizzle ORM. Follow `.agents/skills/nestjs-best-pra
- One feature folder under `src/modules/` - One feature folder under `src/modules/`
- Each feature has `*.module.ts`, `*.controller.ts`, `*.service.ts`, `dto/` - Each feature has `*.module.ts`, `*.controller.ts`, `*.service.ts`, `dto/`
- Share cross-cutting code via `src/common/` (filters, guards, pipes, interceptors) - Share cross-cutting code via `src/common/` (filters, guards, pipes, interceptors)
- Document HTTP endpoints per `.cursor/rules/nestjs-swagger.mdc`
## Tests ## Tests
+21
View File
@@ -2,3 +2,24 @@
# Stored values remain UTC unix milliseconds. Fixed offsets only (no IANA names). # Stored values remain UTC unix milliseconds. Fixed offsets only (no IANA names).
# Examples: GMT+7, GMT+07:00, UTC+7, +07:00, +7, +0700 # Examples: GMT+7, GMT+07:00, UTC+7, +07:00, +7, +0700
DEFAULT_TIMEZONE=GMT+7 DEFAULT_TIMEZONE=GMT+7
PORT=3000
# PostgreSQL connection string (docker-compose default shown)
DATABASE_URL=postgresql://tracking:tracking@localhost:5432/tracking
# Required. Generate with: openssl rand -base64 48
JWT_ACCESS_SECRET=
# Access token lifetime (<number><s|m|h|d>)
JWT_ACCESS_EXPIRES_IN=15m
# Refresh token lifetime in milliseconds (default 7 days)
REFRESH_TOKEN_EXPIRES_IN_MS=604800000
# bcrypt cost factor
BCRYPT_SALT_ROUNDS=10
# OpenAPI UI at /docs (default: on unless NODE_ENV=production)
# SWAGGER_ENABLED=true
# SWAGGER_ENABLED=false
+1
View File
@@ -0,0 +1 @@
bcrypt
+19
View File
@@ -0,0 +1,19 @@
services:
postgres:
image: postgres:16-alpine
ports:
- '5432:5432'
environment:
POSTGRES_USER: tracking
POSTGRES_PASSWORD: tracking
POSTGRES_DB: tracking
volumes:
- tracking_pg_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U tracking -d tracking']
interval: 5s
timeout: 5s
retries: 10
volumes:
tracking_pg_data:
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/database/schema.ts',
out: './drizzle/migrations',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
strict: true,
});
@@ -0,0 +1,27 @@
CREATE TABLE "refresh_tokens" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"token_hash" text NOT NULL,
"access_jti" text NOT NULL,
"expires_at" bigint NOT NULL,
"revoked_at" bigint,
"replaced_by" uuid,
"created_at" bigint NOT NULL
);
--> statement-breakpoint
CREATE TABLE "revoked_access_tokens" (
"jti" text PRIMARY KEY NOT NULL,
"expires_at" bigint NOT NULL
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"username" text NOT NULL,
"password_hash" text NOT NULL,
"created_at" bigint NOT NULL,
"updated_at" bigint NOT NULL
);
--> statement-breakpoint
ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "refresh_tokens_token_hash_unique" ON "refresh_tokens" USING btree ("token_hash");--> statement-breakpoint
CREATE UNIQUE INDEX "users_username_unique" ON "users" USING btree ("username");
@@ -0,0 +1 @@
CREATE INDEX "refresh_tokens_user_id_idx" ON "refresh_tokens" USING btree ("user_id");
+196
View File
@@ -0,0 +1,196 @@
{
"id": "e3f29ef3-7c4b-42df-9f2a-e751897061e8",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.refresh_tokens": {
"name": "refresh_tokens",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"token_hash": {
"name": "token_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_jti": {
"name": "access_jti",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"revoked_at": {
"name": "revoked_at",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"replaced_by": {
"name": "replaced_by",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "bigint",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"refresh_tokens_token_hash_unique": {
"name": "refresh_tokens_token_hash_unique",
"columns": [
{
"expression": "token_hash",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"refresh_tokens_user_id_users_id_fk": {
"name": "refresh_tokens_user_id_users_id_fk",
"tableFrom": "refresh_tokens",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.revoked_access_tokens": {
"name": "revoked_access_tokens",
"schema": "",
"columns": {
"jti": {
"name": "jti",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "bigint",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "bigint",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
{
"expression": "username",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+211
View File
@@ -0,0 +1,211 @@
{
"id": "55ce9d09-18dc-4cf8-8fa0-6b96b229820f",
"prevId": "e3f29ef3-7c4b-42df-9f2a-e751897061e8",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.refresh_tokens": {
"name": "refresh_tokens",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"token_hash": {
"name": "token_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_jti": {
"name": "access_jti",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"revoked_at": {
"name": "revoked_at",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"replaced_by": {
"name": "replaced_by",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "bigint",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"refresh_tokens_token_hash_unique": {
"name": "refresh_tokens_token_hash_unique",
"columns": [
{
"expression": "token_hash",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"refresh_tokens_user_id_idx": {
"name": "refresh_tokens_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"refresh_tokens_user_id_users_id_fk": {
"name": "refresh_tokens_user_id_users_id_fk",
"tableFrom": "refresh_tokens",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.revoked_access_tokens": {
"name": "revoked_access_tokens",
"schema": "",
"columns": {
"jti": {
"name": "jti",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "bigint",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "bigint",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
{
"expression": "username",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1787284452106,
"tag": "0000_jazzy_tomorrow_man",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1787285129382,
"tag": "0001_amusing_maximus",
"breakpoints": true
}
]
}
+11 -1
View File
@@ -3,6 +3,16 @@
"collection": "@nestjs/schematics", "collection": "@nestjs/schematics",
"sourceRoot": "src", "sourceRoot": "src",
"compilerOptions": { "compilerOptions": {
"deleteOutDir": true "deleteOutDir": true,
"plugins": [
{
"name": "@nestjs/swagger",
"options": {
"classValidatorShim": true,
"introspectComments": true,
"dtoFileNameSuffix": [".dto.ts"]
}
}
]
} }
} }
+38 -3
View File
@@ -17,13 +17,34 @@
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:cov": "jest --coverage", "test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json" "test:e2e": "jest --config ./test/jest-e2e.json",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push"
},
"pnpm": {
"onlyBuiltDependencies": [
"bcrypt",
"esbuild"
]
}, },
"dependencies": { "dependencies": {
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.4.7",
"@nestjs/throttler": "^6.5.0",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"drizzle-orm": "^0.45.2",
"libphonenumber-js": "^1.13.11", "libphonenumber-js": "^1.13.11",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"postgres": "^3.4.9",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
}, },
@@ -33,10 +54,13 @@
"@nestjs/cli": "^11.0.0", "@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0", "@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1", "@nestjs/testing": "^11.0.1",
"@types/bcrypt": "^6.0.0",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^7.0.0", "@types/supertest": "^7.0.0",
"drizzle-kit": "^0.31.10",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2", "eslint-plugin-prettier": "^5.2.2",
@@ -49,7 +73,7 @@
"ts-loader": "^9.5.2", "ts-loader": "^9.5.2",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0", "tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3", "typescript": "^6.0.3",
"typescript-eslint": "^8.20.0" "typescript-eslint": "^8.20.0"
}, },
"jest": { "jest": {
@@ -64,8 +88,19 @@
"^.+\\.(t|j)s$": "ts-jest" "^.+\\.(t|j)s$": "ts-jest"
}, },
"collectCoverageFrom": [ "collectCoverageFrom": [
"**/*.(t|j)s" "**/*.(t|j)s",
"!**/*.module.ts",
"!**/main.ts",
"!**/*.dto.ts"
], ],
"coverageThreshold": {
"global": {
"branches": 70,
"functions": 70,
"lines": 80,
"statements": 80
}
},
"coverageDirectory": "../coverage", "coverageDirectory": "../coverage",
"testEnvironment": "node" "testEnvironment": "node"
} }
+1418 -75
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -1,11 +1,20 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Get } from '@nestjs/common';
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public } from './common/decorators/public.decorator';
import { AppService } from './app.service'; import { AppService } from './app.service';
@ApiTags('app')
@Public()
@Controller() @Controller()
export class AppController { export class AppController {
constructor(private readonly appService: AppService) {} constructor(private readonly appService: AppService) {}
@Get() @Get()
@ApiOperation({ summary: 'Health/hello probe' })
@ApiOkResponse({
description: 'Plain text greeting',
schema: { type: 'string', example: 'Hello World!' },
})
getHello(): string { getHello(): string {
return this.appService.getHello(); return this.appService.getHello();
} }
+14 -1
View File
@@ -1,9 +1,22 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { AppService } from './app.service'; import { AppService } from './app.service';
import loadEnv from './config/env';
import { DatabaseModule } from './database/database.module';
import { AuthModule } from './modules/auth/auth.module';
import { UsersModule } from './modules/users/users.module';
@Module({ @Module({
imports: [], imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [loadEnv],
}),
DatabaseModule,
UsersModule,
AuthModule,
],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],
}) })
+12
View File
@@ -0,0 +1,12 @@
export type AuthUser = {
readonly id: string;
readonly username: string;
readonly jti: string;
};
export type JwtAccessPayload = {
readonly sub: string;
readonly username: string;
readonly jti: string;
readonly typ: 'access';
};
+42
View File
@@ -0,0 +1,42 @@
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from '../app.controller';
import { AppService } from '../app.service';
import { configureApp } from './configure-app';
import * as setupSwaggerModule from './swagger/setup-swagger';
describe('configureApp', () => {
let app: INestApplication;
let useGlobalPipesSpy: jest.SpyInstance;
let setupSwaggerSpy: jest.SpyInstance;
beforeAll(async () => {
const moduleRef: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
app = moduleRef.createNestApplication();
useGlobalPipesSpy = jest.spyOn(app, 'useGlobalPipes');
setupSwaggerSpy = jest
.spyOn(setupSwaggerModule, 'setupSwagger')
.mockImplementation(() => undefined);
});
afterAll(async () => {
await app.close();
});
afterEach(() => {
useGlobalPipesSpy.mockClear();
setupSwaggerSpy.mockClear();
});
it('registers ValidationPipe and setupSwagger', () => {
const env = { NODE_ENV: 'test' } as NodeJS.ProcessEnv;
configureApp(app, env);
expect(useGlobalPipesSpy).toHaveBeenCalledWith(expect.any(ValidationPipe));
expect(setupSwaggerSpy).toHaveBeenCalledWith(app, env);
});
});
+17
View File
@@ -0,0 +1,17 @@
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { setupSwagger } from './swagger/setup-swagger';
/** Shared Nest app configuration for bootstrap and E2E. */
export function configureApp(
app: INestApplication,
env: NodeJS.ProcessEnv = process.env,
): void {
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
setupSwagger(app, env);
}
@@ -0,0 +1,47 @@
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
import type { AuthUser } from '../auth/auth-user';
/**
* Mirrors CurrentUser createParamDecorator factory for unit testing.
*/
function extractCurrentUser(
data: keyof AuthUser | undefined,
ctx: ExecutionContext,
): AuthUser | AuthUser[keyof AuthUser] {
const request = ctx.switchToHttp().getRequest<{ user?: AuthUser }>();
const user = request.user;
if (!user) {
throw new UnauthorizedException();
}
return data ? user[data] : user;
}
describe('CurrentUser decorator', () => {
const user: AuthUser = {
id: 'user-1',
username: 'alice',
jti: 'jti-1',
};
const createCtx = (u?: AuthUser): ExecutionContext =>
({
switchToHttp: () => ({
getRequest: () => ({ user: u }),
}),
}) as unknown as ExecutionContext;
it('returns the full AuthUser when no property is specified', () => {
expect(extractCurrentUser(undefined, createCtx(user))).toEqual(user);
});
it('returns a single property when a key is specified', () => {
expect(extractCurrentUser('username', createCtx(user))).toBe('alice');
expect(extractCurrentUser('id', createCtx(user))).toBe('user-1');
});
it('throws UnauthorizedException when user is missing', () => {
expect(() => extractCurrentUser(undefined, createCtx())).toThrow(
UnauthorizedException,
);
});
});
@@ -0,0 +1,24 @@
import {
createParamDecorator,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import type { AuthUser } from '../auth/auth-user';
/**
* Injects the authenticated user from the request (set by JwtAuthGuard).
* Pass a property name to pick a single field, e.g. `@CurrentUser('id')`.
*/
export const CurrentUser = createParamDecorator(
(
data: keyof AuthUser | undefined,
ctx: ExecutionContext,
): AuthUser | AuthUser[keyof AuthUser] => {
const request = ctx.switchToHttp().getRequest<{ user?: AuthUser }>();
const user = request.user;
if (!user) {
throw new UnauthorizedException();
}
return data ? user[data] : user;
},
);
@@ -0,0 +1,6 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
/** Marks a controller class or route handler as publicly accessible (no JWT). */
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
+52
View File
@@ -0,0 +1,52 @@
import { ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtAuthGuard } from './jwt-auth.guard';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
describe('JwtAuthGuard', () => {
const createContext = (
handlerMeta?: boolean,
classMeta?: boolean,
): ExecutionContext => {
const handler = () => undefined;
const controller = class TestController {};
if (handlerMeta !== undefined) {
Reflect.defineMetadata(IS_PUBLIC_KEY, handlerMeta, handler);
}
if (classMeta !== undefined) {
Reflect.defineMetadata(IS_PUBLIC_KEY, classMeta, controller);
}
return {
getHandler: () => handler,
getClass: () => controller,
switchToHttp: () => ({
getRequest: () => ({}),
}),
} as unknown as ExecutionContext;
};
it('allows public methods without JWT', () => {
const guard = new JwtAuthGuard(new Reflector());
const result = guard.canActivate(createContext(true));
expect(result).toBe(true);
});
it('allows public controller classes without JWT', () => {
const guard = new JwtAuthGuard(new Reflector());
const result = guard.canActivate(createContext(undefined, true));
expect(result).toBe(true);
});
it('delegates to AuthGuard when route is protected', () => {
const guard = new JwtAuthGuard(new Reflector());
const spy = jest
.spyOn(Object.getPrototypeOf(JwtAuthGuard.prototype), 'canActivate')
.mockReturnValue(true);
const result = guard.canActivate(createContext());
expect(spy).toHaveBeenCalled();
expect(result).toBe(true);
spy.mockRestore();
});
});
+22
View File
@@ -0,0 +1,22 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
}
+141
View File
@@ -0,0 +1,141 @@
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { SwaggerModule } from '@nestjs/swagger';
import { AppController } from '../../app.controller';
import { AppService } from '../../app.service';
import { AuthController } from '../../modules/auth/auth.controller';
import { AuthService } from '../../modules/auth/auth.service';
import {
createOpenApiDocument,
isSwaggerEnabled,
setupSwagger,
} from './setup-swagger';
describe('isSwaggerEnabled', () => {
it('is disabled in production by default', () => {
expect(isSwaggerEnabled({ NODE_ENV: 'production' })).toBe(false);
});
it('is enabled in production when SWAGGER_ENABLED=true', () => {
expect(
isSwaggerEnabled({
NODE_ENV: 'production',
SWAGGER_ENABLED: 'true',
}),
).toBe(true);
});
it('is enabled when not production', () => {
expect(isSwaggerEnabled({ NODE_ENV: 'development' })).toBe(true);
});
it('is disabled when SWAGGER_ENABLED=false', () => {
expect(
isSwaggerEnabled({
NODE_ENV: 'development',
SWAGGER_ENABLED: 'false',
}),
).toBe(false);
});
});
describe('createOpenApiDocument', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleRef: TestingModule = await Test.createTestingModule({
controllers: [AppController, AuthController],
providers: [
AppService,
{
provide: AuthService,
useValue: {
register: jest.fn(),
login: jest.fn(),
refresh: jest.fn(),
revoke: jest.fn(),
},
},
],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
});
afterAll(async () => {
await app.close();
});
it('includes auth and app paths with bearer scheme', () => {
const document = createOpenApiDocument(app);
expect(document.info.title).toBe('Tracking API');
expect(document.paths['/auth/login']).toBeDefined();
expect(document.paths['/auth/me']).toBeDefined();
expect(document.paths['/']).toBeDefined();
expect(document.components?.securitySchemes?.['access-token']).toEqual(
expect.objectContaining({ type: 'http', scheme: 'bearer' }),
);
});
it('requires bearer on /auth/me but not on public auth posts', () => {
const document = createOpenApiDocument(app);
expect(document.paths['/auth/me']?.get?.security).toEqual([
{ 'access-token': [] },
]);
expect(document.paths['/auth/login']?.post?.security).toBeUndefined();
expect(document.paths['/auth/register']?.post?.security).toBeUndefined();
});
});
describe('setupSwagger', () => {
let app: INestApplication;
let setupSpy: jest.SpyInstance;
beforeAll(async () => {
const moduleRef: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
});
afterAll(async () => {
await app.close();
});
beforeEach(() => {
setupSpy = jest
.spyOn(SwaggerModule, 'setup')
.mockImplementation(() => undefined);
});
afterEach(() => {
setupSpy.mockRestore();
});
it('skips mounting when swagger is disabled', () => {
setupSwagger(app, {
NODE_ENV: 'production',
});
expect(setupSpy).not.toHaveBeenCalled();
});
it('mounts at /docs when swagger is enabled', () => {
setupSwagger(app, {
NODE_ENV: 'test',
});
expect(setupSpy).toHaveBeenCalledWith(
'docs',
app,
expect.any(Object),
expect.objectContaining({ jsonDocumentUrl: 'docs-json' }),
);
});
});
+52
View File
@@ -0,0 +1,52 @@
import { INestApplication } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import type { OpenAPIObject } from '@nestjs/swagger';
export const SWAGGER_PATH = 'docs';
export const SWAGGER_JSON_PATH = 'docs-json';
export const BEARER_AUTH_NAME = 'access-token';
export function isSwaggerEnabled(
env: NodeJS.ProcessEnv = process.env,
): boolean {
if (env.SWAGGER_ENABLED === 'true') {
return true;
}
if (env.SWAGGER_ENABLED === 'false') {
return false;
}
return env.NODE_ENV !== 'production';
}
export function createOpenApiDocument(app: INestApplication): OpenAPIObject {
const config = new DocumentBuilder()
.setTitle('Tracking API')
.setDescription('HTTP API for the Tracking service')
.setVersion('0.0.1')
.addBearerAuth(
{
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'Access token from login or register',
},
BEARER_AUTH_NAME,
)
.build();
return SwaggerModule.createDocument(app, config);
}
export function setupSwagger(
app: INestApplication,
env: NodeJS.ProcessEnv = process.env,
): void {
if (!isSwaggerEnabled(env)) {
return;
}
const document = createOpenApiDocument(app);
SwaggerModule.setup(SWAGGER_PATH, app, document, {
jsonDocumentUrl: SWAGGER_JSON_PATH,
});
}
+78
View File
@@ -0,0 +1,78 @@
import { loadEnv } from './env';
describe('loadEnv', () => {
const valid = {
DATABASE_URL: 'postgresql://user:pass@localhost:5432/tracking',
JWT_ACCESS_SECRET: 'test-secret-at-least-32-chars-long!!',
};
it('loads required values with defaults', () => {
const env = loadEnv(valid);
expect(env.DATABASE_URL).toBe(valid.DATABASE_URL);
expect(env.JWT_ACCESS_SECRET).toBe(valid.JWT_ACCESS_SECRET);
expect(env.PORT).toBe(3000);
expect(env.JWT_ACCESS_EXPIRES_IN).toBe('15m');
expect(env.JWT_ACCESS_EXPIRES_IN_MS).toBe(15 * 60 * 1000);
expect(env.REFRESH_TOKEN_EXPIRES_IN_MS).toBe(7 * 24 * 60 * 60 * 1000);
expect(env.BCRYPT_SALT_ROUNDS).toBe(10);
expect(env.DEFAULT_TIMEZONE).toBe('GMT+7');
});
it('throws when DATABASE_URL is missing', () => {
expect(() =>
loadEnv({ JWT_ACCESS_SECRET: valid.JWT_ACCESS_SECRET }),
).toThrow('DATABASE_URL is not configured');
});
it('throws when JWT_ACCESS_SECRET is missing', () => {
expect(() => loadEnv({ DATABASE_URL: valid.DATABASE_URL })).toThrow(
'JWT_ACCESS_SECRET is not configured',
);
});
it('throws when JWT_ACCESS_SECRET is a placeholder', () => {
expect(() =>
loadEnv({
DATABASE_URL: valid.DATABASE_URL,
JWT_ACCESS_SECRET: 'change-me-to-a-long-random-secret',
}),
).toThrow('known placeholder');
});
it('throws when JWT_ACCESS_SECRET is too short', () => {
expect(() =>
loadEnv({
DATABASE_URL: valid.DATABASE_URL,
JWT_ACCESS_SECRET: 'short-secret',
}),
).toThrow('at least 32 characters');
});
it('parses optional numeric overrides', () => {
const env = loadEnv({
...valid,
PORT: '4000',
BCRYPT_SALT_ROUNDS: '12',
REFRESH_TOKEN_EXPIRES_IN_MS: '3600000',
JWT_ACCESS_EXPIRES_IN: '5m',
DEFAULT_TIMEZONE: 'GMT+0',
});
expect(env.PORT).toBe(4000);
expect(env.BCRYPT_SALT_ROUNDS).toBe(12);
expect(env.REFRESH_TOKEN_EXPIRES_IN_MS).toBe(3_600_000);
expect(env.JWT_ACCESS_EXPIRES_IN).toBe('5m');
expect(env.JWT_ACCESS_EXPIRES_IN_MS).toBe(5 * 60 * 1000);
expect(env.DEFAULT_TIMEZONE).toBe('GMT+0');
});
it('rejects invalid JWT_ACCESS_EXPIRES_IN format', () => {
expect(() =>
loadEnv({
...valid,
JWT_ACCESS_EXPIRES_IN: '900',
}),
).toThrow('must match');
});
});
+115
View File
@@ -0,0 +1,115 @@
export type AppEnv = {
PORT: number;
DATABASE_URL: string;
JWT_ACCESS_SECRET: string;
JWT_ACCESS_EXPIRES_IN: string;
JWT_ACCESS_EXPIRES_IN_MS: number;
REFRESH_TOKEN_EXPIRES_IN_MS: number;
BCRYPT_SALT_ROUNDS: number;
DEFAULT_TIMEZONE: string;
};
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
const FIFTEEN_MINUTES_MS = 15 * 60 * 1000;
const MIN_SECRET_LENGTH = 32;
const PLACEHOLDER_SECRETS = new Set([
'change-me-to-a-long-random-secret',
'changeme',
'secret',
]);
function requireString(name: string, value: string | undefined): string {
if (value === undefined || value.trim() === '') {
throw new Error(`${name} is not configured`);
}
return value.trim();
}
function requireSecret(name: string, value: string | undefined): string {
const secret = requireString(name, value);
if (secret.length < MIN_SECRET_LENGTH) {
throw new Error(`${name} must be at least ${MIN_SECRET_LENGTH} characters`);
}
if (PLACEHOLDER_SECRETS.has(secret.toLowerCase())) {
throw new Error(`${name} is set to a known placeholder value`);
}
return secret;
}
function parsePositiveInt(
name: string,
value: string | undefined,
fallback: number,
): number {
if (value === undefined || value.trim() === '') {
return fallback;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`${name} must be a positive integer`);
}
return parsed;
}
/** Parses forms like 15m, 900s, 1h, 7d into milliseconds. */
export function parseDurationMs(
name: string,
value: string | undefined,
fallbackMs: number,
): { raw: string; ms: number } {
if (value === undefined || value.trim() === '') {
return { raw: '15m', ms: fallbackMs };
}
const trimmed = value.trim();
const match = /^(\d+)([smhd])$/.exec(trimmed);
if (!match) {
throw new Error(`${name} must match <number><s|m|h|d> (e.g. 15m, 7d)`);
}
const amount = Number.parseInt(match[1], 10);
const unit = match[2];
const multipliers: Record<string, number> = {
s: 1000,
m: 60_000,
h: 3_600_000,
d: 86_400_000,
};
return { raw: trimmed, ms: amount * multipliers[unit] };
}
/**
* Validates and normalizes process environment for application boot.
* Throws if required secrets or connection strings are missing.
*/
export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
const accessExpiry = parseDurationMs(
'JWT_ACCESS_EXPIRES_IN',
source.JWT_ACCESS_EXPIRES_IN,
FIFTEEN_MINUTES_MS,
);
return {
PORT: parsePositiveInt('PORT', source.PORT, 3000),
DATABASE_URL: requireString('DATABASE_URL', source.DATABASE_URL),
JWT_ACCESS_SECRET: requireSecret(
'JWT_ACCESS_SECRET',
source.JWT_ACCESS_SECRET,
),
JWT_ACCESS_EXPIRES_IN: accessExpiry.raw,
JWT_ACCESS_EXPIRES_IN_MS: accessExpiry.ms,
REFRESH_TOKEN_EXPIRES_IN_MS: parsePositiveInt(
'REFRESH_TOKEN_EXPIRES_IN_MS',
source.REFRESH_TOKEN_EXPIRES_IN_MS,
SEVEN_DAYS_MS,
),
BCRYPT_SALT_ROUNDS: parsePositiveInt(
'BCRYPT_SALT_ROUNDS',
source.BCRYPT_SALT_ROUNDS,
10,
),
DEFAULT_TIMEZONE: source.DEFAULT_TIMEZONE?.trim() || 'GMT+7',
};
}
/** Nest ConfigModule factory — returns a plain object for ConfigService. */
export default () => loadEnv();
+47
View File
@@ -0,0 +1,47 @@
import {
Global,
Inject,
Injectable,
Module,
OnModuleDestroy,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js';
import postgres, { type Sql } from 'postgres';
import * as schema from './schema';
export const DRIZZLE = Symbol('DRIZZLE');
export const POSTGRES_CLIENT = Symbol('POSTGRES_CLIENT');
export type DrizzleDB = PostgresJsDatabase<typeof schema>;
@Injectable()
class PostgresShutdown implements OnModuleDestroy {
constructor(@Inject(POSTGRES_CLIENT) private readonly client: Sql) {}
async onModuleDestroy(): Promise<void> {
await this.client.end({ timeout: 5 });
}
}
@Global()
@Module({
providers: [
{
provide: POSTGRES_CLIENT,
inject: [ConfigService],
useFactory: (config: ConfigService): Sql => {
const url = config.getOrThrow<string>('DATABASE_URL');
return postgres(url, { max: 10 });
},
},
{
provide: DRIZZLE,
inject: [POSTGRES_CLIENT],
useFactory: (client: Sql): DrizzleDB => drizzle(client, { schema }),
},
PostgresShutdown,
],
exports: [DRIZZLE],
})
export class DatabaseModule {}
+59
View File
@@ -0,0 +1,59 @@
import {
bigint,
index,
pgTable,
text,
uniqueIndex,
uuid,
} from 'drizzle-orm/pg-core';
/**
* Application users. Timestamps are UTC unix milliseconds.
*/
export const users = pgTable(
'users',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
username: text('username').notNull(),
passwordHash: text('password_hash').notNull(),
createdAt: bigint('created_at', { mode: 'number' }).notNull(),
updatedAt: bigint('updated_at', { mode: 'number' }).notNull(),
},
(t) => [uniqueIndex('users_username_unique').on(t.username)],
);
/**
* Refresh sessions. token_hash is SHA-256 of the opaque refresh token.
*/
export const refreshTokens = pgTable(
'refresh_tokens',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
userId: uuid('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
tokenHash: text('token_hash').notNull(),
accessJti: text('access_jti').notNull(),
expiresAt: bigint('expires_at', { mode: 'number' }).notNull(),
revokedAt: bigint('revoked_at', { mode: 'number' }),
replacedBy: uuid('replaced_by'),
createdAt: bigint('created_at', { mode: 'number' }).notNull(),
},
(t) => [
uniqueIndex('refresh_tokens_token_hash_unique').on(t.tokenHash),
index('refresh_tokens_user_id_idx').on(t.userId),
],
);
/**
* Denylist for access JWT jti values after revoke or refresh rotation.
*/
export const revokedAccessTokens = pgTable('revoked_access_tokens', {
jti: text('jti').notNull().primaryKey(),
expiresAt: bigint('expires_at', { mode: 'number' }).notNull(),
});
export type UserRow = typeof users.$inferSelect;
export type NewUserRow = typeof users.$inferInsert;
export type RefreshTokenRow = typeof refreshTokens.$inferSelect;
export type NewRefreshTokenRow = typeof refreshTokens.$inferInsert;
+6 -2
View File
@@ -1,8 +1,12 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { configureApp } from './common/configure-app';
import { loadEnv } from './config/env';
async function bootstrap() { async function bootstrap() {
const env = loadEnv();
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
await app.listen(process.env.PORT ?? 3000); configureApp(app);
await app.listen(env.PORT);
} }
bootstrap(); void bootstrap();
+63
View File
@@ -0,0 +1,63 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
describe('AuthController', () => {
let controller: AuthController;
let authService: jest.Mocked<
Pick<AuthService, 'register' | 'login' | 'refresh' | 'revoke'>
>;
beforeEach(async () => {
authService = {
register: jest.fn().mockResolvedValue({
accessToken: 'a',
refreshToken: 'b'.repeat(64),
}),
login: jest.fn().mockResolvedValue({
accessToken: 'a',
refreshToken: 'b'.repeat(64),
}),
refresh: jest.fn().mockResolvedValue({
accessToken: 'c',
refreshToken: 'd'.repeat(64),
}),
revoke: jest.fn().mockResolvedValue(undefined),
};
const moduleRef: TestingModule = await Test.createTestingModule({
controllers: [AuthController],
providers: [{ provide: AuthService, useValue: authService }],
}).compile();
controller = moduleRef.get(AuthController);
});
it('register delegates to AuthService', async () => {
await controller.register({ username: 'alice', password: 'password123' });
expect(authService.register).toHaveBeenCalledWith('alice', 'password123');
});
it('login delegates to AuthService', async () => {
await controller.login({ username: 'alice', password: 'password123' });
expect(authService.login).toHaveBeenCalledWith('alice', 'password123');
});
it('refresh delegates to AuthService', async () => {
const token = 'e'.repeat(64);
await controller.refresh({ refreshToken: token });
expect(authService.refresh).toHaveBeenCalledWith(token);
});
it('revoke delegates to AuthService', async () => {
const token = 'f'.repeat(64);
await controller.revoke({ refreshToken: token });
expect(authService.revoke).toHaveBeenCalledWith(token);
});
it('me returns id and username', () => {
expect(
controller.me({ id: 'user-1', username: 'alice', jti: 'jti-1' }),
).toEqual({ id: 'user-1', username: 'alice' });
});
});
+93
View File
@@ -0,0 +1,93 @@
import { Body, Controller, Get, HttpCode, Post } from '@nestjs/common';
import {
ApiBadRequestResponse,
ApiBearerAuth,
ApiConflictResponse,
ApiCreatedResponse,
ApiNoContentResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
ApiTooManyRequestsResponse,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import type { AuthUser } from '../../common/auth/auth-user';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { Public } from '../../common/decorators/public.decorator';
import { BEARER_AUTH_NAME } from '../../common/swagger/setup-swagger';
import { AuthService } from './auth.service';
import {
LoginDto,
MeResponseDto,
RefreshTokenDto,
RegisterDto,
TokenPairDto,
} from './dto/auth.dto';
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Public()
@Throttle({ default: { limit: 5, ttl: 60_000 } })
@Post('register')
@ApiOperation({ summary: 'Register a new user' })
@ApiCreatedResponse({ type: TokenPairDto })
@ApiBadRequestResponse({ description: 'Validation failed' })
@ApiConflictResponse({ description: 'Username already registered' })
@ApiTooManyRequestsResponse({ description: 'Rate limit exceeded' })
register(@Body() dto: RegisterDto): Promise<TokenPairDto> {
return this.authService.register(dto.username, dto.password);
}
@Public()
@Throttle({ default: { limit: 5, ttl: 60_000 } })
@Post('login')
@HttpCode(200)
@ApiOperation({ summary: 'Log in with username and password' })
@ApiOkResponse({ type: TokenPairDto })
@ApiBadRequestResponse({ description: 'Validation failed' })
@ApiUnauthorizedResponse({ description: 'Invalid credentials' })
@ApiTooManyRequestsResponse({ description: 'Rate limit exceeded' })
login(@Body() dto: LoginDto): Promise<TokenPairDto> {
return this.authService.login(dto.username, dto.password);
}
@Public()
@Throttle({ default: { limit: 10, ttl: 60_000 } })
@Post('refresh')
@HttpCode(200)
@ApiOperation({
summary: 'Rotate refresh token and issue a new access token',
})
@ApiOkResponse({ type: TokenPairDto })
@ApiBadRequestResponse({ description: 'Validation failed' })
@ApiUnauthorizedResponse({ description: 'Invalid refresh token' })
@ApiTooManyRequestsResponse({ description: 'Rate limit exceeded' })
refresh(@Body() dto: RefreshTokenDto): Promise<TokenPairDto> {
return this.authService.refresh(dto.refreshToken);
}
@Public()
@Throttle({ default: { limit: 10, ttl: 60_000 } })
@Post('revoke')
@HttpCode(204)
@ApiOperation({ summary: 'Revoke a refresh token session' })
@ApiNoContentResponse({ description: 'Session revoked (or already invalid)' })
@ApiBadRequestResponse({ description: 'Validation failed' })
@ApiTooManyRequestsResponse({ description: 'Rate limit exceeded' })
async revoke(@Body() dto: RefreshTokenDto): Promise<void> {
await this.authService.revoke(dto.refreshToken);
}
@Get('me')
@ApiBearerAuth(BEARER_AUTH_NAME)
@ApiOperation({ summary: 'Get the current authenticated user' })
@ApiOkResponse({ type: MeResponseDto })
@ApiUnauthorizedResponse({ description: 'Missing or invalid access token' })
me(@CurrentUser() user: AuthUser): MeResponseDto {
return { id: user.id, username: user.username };
}
}
+44
View File
@@ -0,0 +1,44 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { UsersModule } from '../users/users.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { RefreshTokensRepository } from './refresh-tokens.repository';
import { RevokedAccessTokensRepository } from './revoked-access-tokens.repository';
import { JwtStrategy } from './strategies/jwt.strategy';
@Module({
imports: [
UsersModule,
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.getOrThrow<string>('JWT_ACCESS_SECRET'),
signOptions: {
expiresIn: config.getOrThrow<string>(
'JWT_ACCESS_EXPIRES_IN',
) as `${number}${'s' | 'm' | 'h' | 'd'}`,
},
}),
}),
ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]),
],
controllers: [AuthController],
providers: [
AuthService,
RefreshTokensRepository,
RevokedAccessTokensRepository,
JwtStrategy,
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: ThrottlerGuard },
],
exports: [AuthService],
})
export class AuthModule {}
+270
View File
@@ -0,0 +1,270 @@
import { ConflictException, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { Test, TestingModule } from '@nestjs/testing';
import * as bcrypt from 'bcrypt';
import { createHash } from 'node:crypto';
import { DateTime } from '../../common/value-objects/date-time/date-time';
import type { User } from '../users/user';
import { UsersService } from '../users/users.service';
import { AuthService } from './auth.service';
import {
RefreshTokensRepository,
type RefreshSession,
} from './refresh-tokens.repository';
import { RevokedAccessTokensRepository } from './revoked-access-tokens.repository';
describe('AuthService', () => {
let service: AuthService;
let usersService: jest.Mocked<
Pick<UsersService, 'create' | 'findByUsername' | 'findById'>
>;
let jwtService: jest.Mocked<Pick<JwtService, 'signAsync'>>;
let config: { getOrThrow: jest.Mock };
let refreshTokensRepository: jest.Mocked<
Pick<
RefreshTokensRepository,
| 'create'
| 'findByTokenHash'
| 'claimForRotation'
| 'setReplacedBy'
| 'markRevoked'
| 'revokeAllForUser'
>
>;
let revokedAccessTokensRepository: jest.Mocked<
Pick<RevokedAccessTokensRepository, 'add' | 'exists'>
>;
const now = DateTime.fromUnixMs(1_700_000_000_000);
let user: User;
beforeEach(async () => {
user = {
id: 'user-1',
username: 'alice',
passwordHash: await bcrypt.hash('password123', 4),
createdAt: now,
updatedAt: now,
};
usersService = {
create: jest.fn(),
findByUsername: jest.fn(),
findById: jest.fn(),
};
jwtService = {
signAsync: jest.fn().mockResolvedValue('access.jwt.token'),
};
config = {
getOrThrow: jest.fn((key: string) => {
const values: Record<string, string | number> = {
BCRYPT_SALT_ROUNDS: 4,
JWT_ACCESS_EXPIRES_IN: '15m',
JWT_ACCESS_EXPIRES_IN_MS: 15 * 60 * 1000,
REFRESH_TOKEN_EXPIRES_IN_MS: 7 * 24 * 60 * 60 * 1000,
};
return values[key];
}),
};
refreshTokensRepository = {
create: jest.fn().mockImplementation(async (input) => ({
id: 'session-new',
userId: input.userId,
tokenHash: input.tokenHash,
accessJti: input.accessJti,
expiresAt: input.expiresAt,
revokedAt: null,
replacedBy: null,
createdAt: now,
})),
findByTokenHash: jest.fn(),
claimForRotation: jest.fn(),
setReplacedBy: jest.fn(),
markRevoked: jest.fn(),
revokeAllForUser: jest.fn().mockResolvedValue([]),
};
revokedAccessTokensRepository = {
add: jest.fn(),
exists: jest.fn(),
};
const moduleRef: TestingModule = await Test.createTestingModule({
providers: [
AuthService,
{ provide: UsersService, useValue: usersService },
{ provide: JwtService, useValue: jwtService },
{ provide: ConfigService, useValue: config },
{ provide: RefreshTokensRepository, useValue: refreshTokensRepository },
{
provide: RevokedAccessTokensRepository,
useValue: revokedAccessTokensRepository,
},
],
}).compile();
service = moduleRef.get(AuthService);
});
it('register creates user and returns token pair', async () => {
usersService.findByUsername.mockResolvedValue(null);
usersService.create.mockResolvedValue(user);
const pair = await service.register('Alice', 'password123');
expect(usersService.create).toHaveBeenCalled();
expect(pair.accessToken).toBe('access.jwt.token');
expect(pair.refreshToken).toHaveLength(64);
expect(Object.keys(pair).sort()).toEqual(['accessToken', 'refreshToken']);
expect(refreshTokensRepository.create).toHaveBeenCalled();
});
it('register throws ConflictException when username exists', async () => {
usersService.findByUsername.mockResolvedValue(user);
await expect(
service.register('alice', 'password123'),
).rejects.toBeInstanceOf(ConflictException);
expect(usersService.create).not.toHaveBeenCalled();
});
it('login returns tokens for valid credentials', async () => {
usersService.findByUsername.mockResolvedValue(user);
const pair = await service.login('alice', 'password123');
expect(pair.accessToken).toBe('access.jwt.token');
expect(pair.refreshToken).toHaveLength(64);
});
it('login throws UnauthorizedException for unknown user', async () => {
usersService.findByUsername.mockResolvedValue(null);
await expect(service.login('alice', 'password123')).rejects.toBeInstanceOf(
UnauthorizedException,
);
});
it('login throws UnauthorizedException for wrong password', async () => {
usersService.findByUsername.mockResolvedValue(user);
await expect(service.login('alice', 'wrong-pass')).rejects.toBeInstanceOf(
UnauthorizedException,
);
});
it('refresh rotates tokens and denylists old access jti', async () => {
const rawRefresh = 'a'.repeat(64);
const session: RefreshSession = {
id: 'session-old',
userId: user.id,
tokenHash: createHash('sha256').update(rawRefresh).digest('hex'),
accessJti: 'jti-old',
expiresAt: DateTime.fromUnixMs(Date.now() + 60_000),
revokedAt: DateTime.fromUnixMs(Date.now()),
replacedBy: null,
createdAt: now,
};
refreshTokensRepository.claimForRotation.mockResolvedValue(session);
usersService.findById.mockResolvedValue(user);
const pair = await service.refresh(rawRefresh);
expect(revokedAccessTokensRepository.add).toHaveBeenCalledWith(
'jti-old',
expect.any(DateTime),
);
expect(refreshTokensRepository.setReplacedBy).toHaveBeenCalledWith(
'session-old',
'session-new',
);
expect(pair.accessToken).toBe('access.jwt.token');
});
it('refresh reuse revokes all user sessions and access jtis', async () => {
const rawRefresh = 'b'.repeat(64);
const session: RefreshSession = {
id: 'session-old',
userId: user.id,
tokenHash: createHash('sha256').update(rawRefresh).digest('hex'),
accessJti: 'jti-old',
expiresAt: DateTime.fromUnixMs(Date.now() + 60_000),
revokedAt: DateTime.fromUnixMs(Date.now() - 1000),
replacedBy: 'session-new',
createdAt: now,
};
refreshTokensRepository.claimForRotation.mockResolvedValue(null);
refreshTokensRepository.findByTokenHash.mockResolvedValue(session);
refreshTokensRepository.revokeAllForUser.mockResolvedValue([
{
...session,
id: 'other-session',
accessJti: 'jti-other',
revokedAt: null,
},
]);
await expect(service.refresh(rawRefresh)).rejects.toBeInstanceOf(
UnauthorizedException,
);
expect(refreshTokensRepository.revokeAllForUser).toHaveBeenCalledWith(
user.id,
);
expect(revokedAccessTokensRepository.add).toHaveBeenCalledWith(
'jti-other',
expect.any(DateTime),
);
});
it('refresh rejects expired tokens', async () => {
const rawRefresh = 'c'.repeat(64);
const session: RefreshSession = {
id: 'session-old',
userId: user.id,
tokenHash: createHash('sha256').update(rawRefresh).digest('hex'),
accessJti: 'jti-old',
expiresAt: DateTime.fromUnixMs(Date.now() - 1000),
revokedAt: DateTime.fromUnixMs(Date.now()),
replacedBy: null,
createdAt: now,
};
refreshTokensRepository.claimForRotation.mockResolvedValue(session);
await expect(service.refresh(rawRefresh)).rejects.toThrow(
'Invalid refresh token',
);
});
it('revoke marks session revoked and denylists access jti', async () => {
const rawRefresh = 'd'.repeat(64);
const session: RefreshSession = {
id: 'session-1',
userId: user.id,
tokenHash: createHash('sha256').update(rawRefresh).digest('hex'),
accessJti: 'jti-1',
expiresAt: DateTime.fromUnixMs(Date.now() + 60_000),
revokedAt: null,
replacedBy: null,
createdAt: now,
};
refreshTokensRepository.findByTokenHash.mockResolvedValue(session);
refreshTokensRepository.markRevoked.mockResolvedValue(true);
await service.revoke(rawRefresh);
expect(refreshTokensRepository.markRevoked).toHaveBeenCalledWith(
'session-1',
);
expect(revokedAccessTokensRepository.add).toHaveBeenCalledWith(
'jti-1',
expect.any(DateTime),
);
});
it('revoke is a no-op for unknown refresh tokens', async () => {
refreshTokensRepository.findByTokenHash.mockResolvedValue(null);
await expect(service.revoke('unknown')).resolves.toBeUndefined();
expect(refreshTokensRepository.markRevoked).not.toHaveBeenCalled();
});
});
+163
View File
@@ -0,0 +1,163 @@
import {
ConflictException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import type { JwtAccessPayload } from '../../common/auth/auth-user';
import { DateTime } from '../../common/value-objects/date-time/date-time';
import type { User } from '../users/user';
import { UsersService } from '../users/users.service';
import { RefreshTokensRepository } from './refresh-tokens.repository';
import { RevokedAccessTokensRepository } from './revoked-access-tokens.repository';
export type TokenPair = {
readonly accessToken: string;
readonly refreshToken: string;
};
/** Precomputed bcrypt hash used only to equalize login timing on unknown users. */
const DUMMY_PASSWORD_HASH =
'$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy';
@Injectable()
export class AuthService {
constructor(
private readonly usersService: UsersService,
private readonly jwtService: JwtService,
private readonly config: ConfigService,
private readonly refreshTokensRepository: RefreshTokensRepository,
private readonly revokedAccessTokensRepository: RevokedAccessTokensRepository,
) {}
async register(username: string, password: string): Promise<TokenPair> {
const existing = await this.usersService.findByUsername(username);
if (existing) {
throw new ConflictException('Username already registered');
}
const saltRounds = this.config.getOrThrow<number>('BCRYPT_SALT_ROUNDS');
const passwordHash = await bcrypt.hash(password, saltRounds);
const user = await this.usersService.create(username, passwordHash);
const { tokens } = await this.issueTokenPair(user);
return tokens;
}
async login(username: string, password: string): Promise<TokenPair> {
const user = await this.usersService.findByUsername(username);
const passwordHash = user?.passwordHash ?? DUMMY_PASSWORD_HASH;
const match = await bcrypt.compare(password, passwordHash);
if (!user || !match) {
throw new UnauthorizedException('Invalid credentials');
}
const { tokens } = await this.issueTokenPair(user);
return tokens;
}
async refresh(refreshToken: string): Promise<TokenPair> {
const tokenHash = this.hashRefreshToken(refreshToken);
const claimed =
await this.refreshTokensRepository.claimForRotation(tokenHash);
if (!claimed) {
const existing =
await this.refreshTokensRepository.findByTokenHash(tokenHash);
if (existing) {
await this.revokeAllSessionsForUser(existing.userId);
}
throw new UnauthorizedException('Invalid refresh token');
}
if (claimed.expiresAt.value <= Date.now()) {
throw new UnauthorizedException('Invalid refresh token');
}
const user = await this.usersService.findById(claimed.userId);
if (!user) {
throw new UnauthorizedException('Invalid refresh token');
}
await this.denylistAccessJti(claimed.accessJti);
const issued = await this.issueTokenPair(user);
await this.refreshTokensRepository.setReplacedBy(
claimed.id,
issued.sessionId,
);
return issued.tokens;
}
async revoke(refreshToken: string): Promise<void> {
const tokenHash = this.hashRefreshToken(refreshToken);
const session =
await this.refreshTokensRepository.findByTokenHash(tokenHash);
if (!session || session.revokedAt !== null) {
return;
}
const claimed = await this.refreshTokensRepository.markRevoked(session.id);
if (claimed) {
await this.denylistAccessJti(session.accessJti);
}
}
async isAccessJtiRevoked(jti: string): Promise<boolean> {
return this.revokedAccessTokensRepository.exists(jti);
}
private async revokeAllSessionsForUser(userId: string): Promise<void> {
const revoked = await this.refreshTokensRepository.revokeAllForUser(userId);
await Promise.all(
revoked.map((session) => this.denylistAccessJti(session.accessJti)),
);
}
private async issueTokenPair(
user: User,
): Promise<{ tokens: TokenPair; sessionId: string }> {
const jti = randomUUID();
const payload: JwtAccessPayload = {
sub: user.id,
username: user.username,
jti,
typ: 'access',
};
const expiresIn = this.config.getOrThrow<string>('JWT_ACCESS_EXPIRES_IN');
const accessToken = await this.jwtService.signAsync(
{ ...payload },
{ expiresIn: expiresIn as `${number}${'s' | 'm' | 'h' | 'd'}` },
);
const refreshToken = randomBytes(32).toString('hex');
const refreshMs = this.config.getOrThrow<number>(
'REFRESH_TOKEN_EXPIRES_IN_MS',
);
const expiresAt = DateTime.fromUnixMs(Date.now() + refreshMs);
const session = await this.refreshTokensRepository.create({
userId: user.id,
tokenHash: this.hashRefreshToken(refreshToken),
accessJti: jti,
expiresAt,
});
return {
tokens: { accessToken, refreshToken },
sessionId: session.id,
};
}
private hashRefreshToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
private async denylistAccessJti(jti: string): Promise<void> {
const ttlMs = this.config.getOrThrow<number>('JWT_ACCESS_EXPIRES_IN_MS');
const expiresAt = DateTime.fromUnixMs(Date.now() + ttlMs);
await this.revokedAccessTokensRepository.add(jti, expiresAt);
}
}
+77
View File
@@ -0,0 +1,77 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, Matches, MaxLength, MinLength } from 'class-validator';
import type { TokenPair } from '../auth.service';
export class RegisterDto {
@ApiProperty({
example: 'alice',
description: 'Letters, numbers, and underscores only',
})
@IsString()
@MinLength(3)
@MaxLength(32)
@Matches(/^[a-zA-Z0-9_]+$/, {
message: 'username must contain only letters, numbers, and underscores',
})
username!: string;
@ApiProperty({
example: 'password123',
format: 'password',
writeOnly: true,
})
@IsString()
@MinLength(8)
@MaxLength(72)
password!: string;
}
export class LoginDto {
@ApiProperty({ example: 'alice' })
@IsString()
@MinLength(3)
@MaxLength(32)
username!: string;
@ApiProperty({
example: 'password123',
format: 'password',
writeOnly: true,
})
@IsString()
@MinLength(8)
@MaxLength(72)
password!: string;
}
export class RefreshTokenDto {
@ApiProperty({
example: 'a'.repeat(64),
description: 'Opaque refresh token from login or register',
})
@IsString()
@MinLength(32)
refreshToken!: string;
}
export class TokenPairDto implements TokenPair {
@ApiProperty({
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.example',
description: 'JWT access token',
})
accessToken!: string;
@ApiProperty({
example: 'a'.repeat(64),
description: 'Opaque refresh token',
})
refreshToken!: string;
}
export class MeResponseDto {
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
id!: string;
@ApiProperty({ example: 'alice' })
username!: string;
}
@@ -0,0 +1,116 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, eq, isNull } from 'drizzle-orm';
import { refreshTokens, type RefreshTokenRow } from '../../database/schema';
import { DateTime } from '../../common/value-objects/date-time/date-time';
import { DRIZZLE, type DrizzleDB } from '../../database/database.module';
export type RefreshSession = {
readonly id: string;
readonly userId: string;
readonly tokenHash: string;
readonly accessJti: string;
readonly expiresAt: DateTime;
readonly revokedAt: DateTime | null;
readonly replacedBy: string | null;
readonly createdAt: DateTime;
};
export type CreateRefreshSessionInput = {
readonly userId: string;
readonly tokenHash: string;
readonly accessJti: string;
readonly expiresAt: DateTime;
};
@Injectable()
export class RefreshTokensRepository {
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
async create(input: CreateRefreshSessionInput): Promise<RefreshSession> {
const now = DateTime.fromUnixMs(Date.now());
const [row] = await this.db
.insert(refreshTokens)
.values({
userId: input.userId,
tokenHash: input.tokenHash,
accessJti: input.accessJti,
expiresAt: input.expiresAt.value,
createdAt: now.value,
})
.returning();
return this.toDomain(row);
}
async findByTokenHash(tokenHash: string): Promise<RefreshSession | null> {
const [row] = await this.db
.select()
.from(refreshTokens)
.where(eq(refreshTokens.tokenHash, tokenHash))
.limit(1);
return row ? this.toDomain(row) : null;
}
/**
* Atomically claims a session for rotation. Returns null if already revoked
* or missing (loser of a concurrent refresh race).
*/
async claimForRotation(tokenHash: string): Promise<RefreshSession | null> {
const now = DateTime.fromUnixMs(Date.now());
const [row] = await this.db
.update(refreshTokens)
.set({ revokedAt: now.value })
.where(
and(
eq(refreshTokens.tokenHash, tokenHash),
isNull(refreshTokens.revokedAt),
),
)
.returning();
return row ? this.toDomain(row) : null;
}
async setReplacedBy(id: string, replacedBy: string): Promise<void> {
await this.db
.update(refreshTokens)
.set({ replacedBy })
.where(eq(refreshTokens.id, id));
}
async markRevoked(id: string): Promise<boolean> {
const now = DateTime.fromUnixMs(Date.now());
const rows = await this.db
.update(refreshTokens)
.set({ revokedAt: now.value })
.where(and(eq(refreshTokens.id, id), isNull(refreshTokens.revokedAt)))
.returning({ id: refreshTokens.id });
return rows.length > 0;
}
async revokeAllForUser(userId: string): Promise<RefreshSession[]> {
const now = DateTime.fromUnixMs(Date.now());
const rows = await this.db
.update(refreshTokens)
.set({ revokedAt: now.value })
.where(
and(eq(refreshTokens.userId, userId), isNull(refreshTokens.revokedAt)),
)
.returning();
return rows.map((row) => this.toDomain(row));
}
private toDomain(row: RefreshTokenRow): RefreshSession {
return {
id: row.id,
userId: row.userId,
tokenHash: row.tokenHash,
accessJti: row.accessJti,
expiresAt: DateTime.fromUnixMs(row.expiresAt),
revokedAt:
row.revokedAt === null || row.revokedAt === undefined
? null
: DateTime.fromUnixMs(row.revokedAt),
replacedBy: row.replacedBy ?? null,
createdAt: DateTime.fromUnixMs(row.createdAt),
};
}
}
@@ -0,0 +1,26 @@
import { Inject, Injectable } from '@nestjs/common';
import { eq } from 'drizzle-orm';
import { revokedAccessTokens } from '../../database/schema';
import { DateTime } from '../../common/value-objects/date-time/date-time';
import { DRIZZLE, type DrizzleDB } from '../../database/database.module';
@Injectable()
export class RevokedAccessTokensRepository {
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
async add(jti: string, expiresAt: DateTime): Promise<void> {
await this.db
.insert(revokedAccessTokens)
.values({ jti, expiresAt: expiresAt.value })
.onConflictDoNothing();
}
async exists(jti: string): Promise<boolean> {
const [row] = await this.db
.select({ jti: revokedAccessTokens.jti })
.from(revokedAccessTokens)
.where(eq(revokedAccessTokens.jti, jti))
.limit(1);
return row !== undefined;
}
}
@@ -0,0 +1,82 @@
import { UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import type { User } from '../../users/user';
import { UsersService } from '../../users/users.service';
import { RevokedAccessTokensRepository } from '../revoked-access-tokens.repository';
import { JwtStrategy } from './jwt.strategy';
describe('JwtStrategy', () => {
let strategy: JwtStrategy;
let usersService: jest.Mocked<Pick<UsersService, 'findById'>>;
let revoked: jest.Mocked<Pick<RevokedAccessTokensRepository, 'exists'>>;
const now = DateTime.fromUnixMs(1_700_000_000_000);
const user: User = {
id: 'user-1',
username: 'alice',
passwordHash: 'hash',
createdAt: now,
updatedAt: now,
};
beforeEach(async () => {
usersService = { findById: jest.fn() };
revoked = { exists: jest.fn() };
const moduleRef: TestingModule = await Test.createTestingModule({
providers: [
JwtStrategy,
{
provide: ConfigService,
useValue: {
getOrThrow: () => 'test-secret-at-least-32-characters-long!!',
},
},
{ provide: UsersService, useValue: usersService },
{ provide: RevokedAccessTokensRepository, useValue: revoked },
],
}).compile();
strategy = moduleRef.get(JwtStrategy);
});
it('returns AuthUser for a valid access payload', async () => {
revoked.exists.mockResolvedValue(false);
usersService.findById.mockResolvedValue(user);
await expect(
strategy.validate({
sub: 'user-1',
username: 'alice',
jti: 'jti-1',
typ: 'access',
}),
).resolves.toEqual({ id: 'user-1', username: 'alice', jti: 'jti-1' });
});
it('rejects revoked access tokens', async () => {
revoked.exists.mockResolvedValue(true);
await expect(
strategy.validate({
sub: 'user-1',
username: 'alice',
jti: 'jti-1',
typ: 'access',
}),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('rejects non-access token types', async () => {
await expect(
strategy.validate({
sub: 'user-1',
username: 'alice',
jti: 'jti-1',
typ: 'refresh' as 'access',
}),
).rejects.toBeInstanceOf(UnauthorizedException);
});
});
@@ -0,0 +1,50 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import type {
AuthUser,
JwtAccessPayload,
} from '../../../common/auth/auth-user';
import { UsersService } from '../../users/users.service';
import { RevokedAccessTokensRepository } from '../revoked-access-tokens.repository';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(
config: ConfigService,
private readonly usersService: UsersService,
private readonly revokedAccessTokensRepository: RevokedAccessTokensRepository,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.getOrThrow<string>('JWT_ACCESS_SECRET'),
algorithms: ['HS256'],
});
}
async validate(payload: JwtAccessPayload): Promise<AuthUser> {
if (payload.typ !== 'access' || !payload.sub || !payload.jti) {
throw new UnauthorizedException('Invalid access token');
}
const revoked = await this.revokedAccessTokensRepository.exists(
payload.jti,
);
if (revoked) {
throw new UnauthorizedException('Access token revoked');
}
const user = await this.usersService.findById(payload.sub);
if (!user) {
throw new UnauthorizedException('User not found');
}
return {
id: user.id,
username: user.username,
jti: payload.jti,
};
}
}
+146
View File
@@ -0,0 +1,146 @@
import { Test, TestingModule } from '@nestjs/testing';
import { DRIZZLE } from '../../database/database.module';
import { DateTime } from '../../common/value-objects/date-time/date-time';
import { RefreshTokensRepository } from './refresh-tokens.repository';
import { RevokedAccessTokensRepository } from './revoked-access-tokens.repository';
describe('RefreshTokensRepository', () => {
let repository: RefreshTokensRepository;
const returning = jest.fn();
const where = jest.fn(() => ({ returning, limit: jest.fn() }));
const set = jest.fn(() => ({ where }));
const values = jest.fn(() => ({ returning }));
const insert = jest.fn(() => ({ values }));
const update = jest.fn(() => ({ set }));
const from = jest.fn(() => ({ where }));
const select = jest.fn(() => ({ from }));
const db = { insert, update, select };
beforeEach(async () => {
jest.clearAllMocks();
const moduleRef: TestingModule = await Test.createTestingModule({
providers: [RefreshTokensRepository, { provide: DRIZZLE, useValue: db }],
}).compile();
repository = moduleRef.get(RefreshTokensRepository);
});
it('create returns a domain session', async () => {
returning.mockResolvedValue([
{
id: 'session-1',
userId: 'user-1',
tokenHash: 'hash',
accessJti: 'jti-1',
expiresAt: 1_700_000_100_000,
revokedAt: null,
replacedBy: null,
createdAt: 1_700_000_000_000,
},
]);
const session = await repository.create({
userId: 'user-1',
tokenHash: 'hash',
accessJti: 'jti-1',
expiresAt: DateTime.fromUnixMs(1_700_000_100_000),
});
expect(session.id).toBe('session-1');
expect(session.revokedAt).toBeNull();
});
it('claimForRotation returns null when no row claimed', async () => {
returning.mockResolvedValue([]);
await expect(repository.claimForRotation('hash')).resolves.toBeNull();
});
it('findByTokenHash returns a session', async () => {
const limitFn = jest.fn().mockResolvedValue([
{
id: 'session-1',
userId: 'user-1',
tokenHash: 'hash',
accessJti: 'jti-1',
expiresAt: 1_700_000_100_000,
revokedAt: null,
replacedBy: null,
createdAt: 1_700_000_000_000,
},
]);
where.mockReturnValueOnce({ limit: limitFn, returning });
const session = await repository.findByTokenHash('hash');
expect(session?.id).toBe('session-1');
});
it('setReplacedBy updates the row', async () => {
where.mockReturnValueOnce({ returning, limit: jest.fn() });
await repository.setReplacedBy('session-1', 'session-2');
expect(set).toHaveBeenCalledWith({ replacedBy: 'session-2' });
});
it('markRevoked returns true when a row is updated', async () => {
returning.mockResolvedValue([{ id: 'session-1' }]);
await expect(repository.markRevoked('session-1')).resolves.toBe(true);
});
it('revokeAllForUser returns revoked sessions', async () => {
returning.mockResolvedValue([
{
id: 'session-1',
userId: 'user-1',
tokenHash: 'hash',
accessJti: 'jti-1',
expiresAt: 1_700_000_100_000,
revokedAt: 1_700_000_050_000,
replacedBy: null,
createdAt: 1_700_000_000_000,
},
]);
const sessions = await repository.revokeAllForUser('user-1');
expect(sessions).toHaveLength(1);
expect(sessions[0].accessJti).toBe('jti-1');
});
});
describe('RevokedAccessTokensRepository', () => {
let repository: RevokedAccessTokensRepository;
const limit = jest.fn();
const where = jest.fn(() => ({ limit }));
const from = jest.fn(() => ({ where }));
const select = jest.fn(() => ({ from }));
const onConflictDoNothing = jest.fn();
const values = jest.fn(() => ({ onConflictDoNothing }));
const insert = jest.fn(() => ({ values }));
const db = { insert, select };
beforeEach(async () => {
jest.clearAllMocks();
const moduleRef: TestingModule = await Test.createTestingModule({
providers: [
RevokedAccessTokensRepository,
{ provide: DRIZZLE, useValue: db },
],
}).compile();
repository = moduleRef.get(RevokedAccessTokensRepository);
});
it('add inserts a denylist row', async () => {
onConflictDoNothing.mockResolvedValue(undefined);
await repository.add('jti-1', DateTime.fromUnixMs(1_700_000_100_000));
expect(values).toHaveBeenCalledWith({
jti: 'jti-1',
expiresAt: 1_700_000_100_000,
});
});
it('exists returns true when jti is present', async () => {
limit.mockResolvedValue([{ jti: 'jti-1' }]);
await expect(repository.exists('jti-1')).resolves.toBe(true);
});
it('exists returns false when jti is absent', async () => {
limit.mockResolvedValue([]);
await expect(repository.exists('missing')).resolves.toBe(false);
});
});
+14
View File
@@ -0,0 +1,14 @@
import { DateTime } from '../../common/value-objects/date-time/date-time';
export type User = {
readonly id: string;
readonly username: string;
readonly passwordHash: string;
readonly createdAt: DateTime;
readonly updatedAt: DateTime;
};
export type CreateUserInput = {
readonly username: string;
readonly passwordHash: string;
};
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { UsersRepository } from './users.repository';
import { UsersService } from './users.service';
@Module({
providers: [UsersRepository, UsersService],
exports: [UsersService],
})
export class UsersModule {}
@@ -0,0 +1,95 @@
import { ConflictException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { DRIZZLE } from '../../database/database.module';
import { UsersRepository } from './users.repository';
describe('UsersRepository', () => {
let repository: UsersRepository;
const limit = jest.fn();
const where = jest.fn(() => ({ limit }));
const from = jest.fn(() => ({ where }));
const select = jest.fn(() => ({ from }));
const returning = jest.fn();
const values = jest.fn(() => ({ returning }));
const insert = jest.fn(() => ({ values }));
const db = { select, insert };
beforeEach(async () => {
jest.clearAllMocks();
const moduleRef: TestingModule = await Test.createTestingModule({
providers: [UsersRepository, { provide: DRIZZLE, useValue: db }],
}).compile();
repository = moduleRef.get(UsersRepository);
});
it('findById maps a row to domain User', async () => {
limit.mockResolvedValue([
{
id: 'user-1',
username: 'alice',
passwordHash: 'hash',
createdAt: 1_700_000_000_000,
updatedAt: 1_700_000_000_000,
},
]);
const user = await repository.findById('user-1');
expect(user).toMatchObject({ id: 'user-1', username: 'alice' });
expect(user?.createdAt.value).toBe(1_700_000_000_000);
});
it('findById returns null when missing', async () => {
limit.mockResolvedValue([]);
await expect(repository.findById('missing')).resolves.toBeNull();
});
it('create inserts lowercase username', async () => {
returning.mockResolvedValue([
{
id: 'user-1',
username: 'alice',
passwordHash: 'hash',
createdAt: 1_700_000_000_000,
updatedAt: 1_700_000_000_000,
},
]);
const user = await repository.create({
username: 'Alice',
passwordHash: 'hash',
});
expect(user.username).toBe('alice');
expect(values).toHaveBeenCalledWith(
expect.objectContaining({ username: 'alice', passwordHash: 'hash' }),
);
});
it('findByUsername maps a row', async () => {
limit.mockResolvedValue([
{
id: 'user-1',
username: 'alice',
passwordHash: 'hash',
createdAt: 1_700_000_000_000,
updatedAt: 1_700_000_000_000,
},
]);
const user = await repository.findByUsername('Alice');
expect(user?.username).toBe('alice');
});
it('create maps unique violations to ConflictException', async () => {
returning.mockRejectedValue({ code: '23505' });
await expect(
repository.create({ username: 'alice', passwordHash: 'hash' }),
).rejects.toBeInstanceOf(ConflictException);
});
it('create rethrows unknown errors', async () => {
returning.mockRejectedValue(new Error('db down'));
await expect(
repository.create({ username: 'alice', passwordHash: 'hash' }),
).rejects.toThrow('db down');
});
});
+61
View File
@@ -0,0 +1,61 @@
import { ConflictException, Inject, Injectable } from '@nestjs/common';
import { eq } from 'drizzle-orm';
import { users, type UserRow } from '../../database/schema';
import { DateTime } from '../../common/value-objects/date-time/date-time';
import { DRIZZLE, type DrizzleDB } from '../../database/database.module';
import type { CreateUserInput, User } from './user';
@Injectable()
export class UsersRepository {
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
async findById(id: string): Promise<User | null> {
const [row] = await this.db
.select()
.from(users)
.where(eq(users.id, id))
.limit(1);
return row ? this.toDomain(row) : null;
}
async findByUsername(username: string): Promise<User | null> {
const normalized = username.toLowerCase();
const [row] = await this.db
.select()
.from(users)
.where(eq(users.username, normalized))
.limit(1);
return row ? this.toDomain(row) : null;
}
async create(input: CreateUserInput): Promise<User> {
const now = DateTime.fromUnixMs(Date.now());
try {
const [row] = await this.db
.insert(users)
.values({
username: input.username.toLowerCase(),
passwordHash: input.passwordHash,
createdAt: now.value,
updatedAt: now.value,
})
.returning();
return this.toDomain(row);
} catch (error) {
if ((error as { code?: string }).code === '23505') {
throw new ConflictException('Username already registered');
}
throw error;
}
}
private toDomain(row: UserRow): User {
return {
id: row.id,
username: row.username,
passwordHash: row.passwordHash,
createdAt: DateTime.fromUnixMs(row.createdAt),
updatedAt: DateTime.fromUnixMs(row.updatedAt),
};
}
}
+71
View File
@@ -0,0 +1,71 @@
import { ConflictException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { DateTime } from '../../common/value-objects/date-time/date-time';
import type { User } from './user';
import { UsersRepository } from './users.repository';
import { UsersService } from './users.service';
describe('UsersService', () => {
let service: UsersService;
let repository: jest.Mocked<
Pick<UsersRepository, 'findById' | 'findByUsername' | 'create'>
>;
const now = DateTime.fromUnixMs(1_700_000_000_000);
const sampleUser: User = {
id: 'user-1',
username: 'alice',
passwordHash: 'hashed',
createdAt: now,
updatedAt: now,
};
beforeEach(async () => {
repository = {
findById: jest.fn(),
findByUsername: jest.fn(),
create: jest.fn(),
};
const moduleRef: TestingModule = await Test.createTestingModule({
providers: [
UsersService,
{ provide: UsersRepository, useValue: repository },
],
}).compile();
service = moduleRef.get(UsersService);
});
it('findById delegates to repository', async () => {
repository.findById.mockResolvedValue(sampleUser);
await expect(service.findById('user-1')).resolves.toEqual(sampleUser);
});
it('create stores lowercase username', async () => {
repository.findByUsername.mockResolvedValue(null);
repository.create.mockResolvedValue(sampleUser);
await service.create('Alice', 'hashed');
expect(repository.findByUsername).toHaveBeenCalledWith('alice');
expect(repository.create).toHaveBeenCalledWith({
username: 'alice',
passwordHash: 'hashed',
});
});
it('create throws ConflictException when username exists', async () => {
repository.findByUsername.mockResolvedValue(sampleUser);
await expect(service.create('alice', 'hashed')).rejects.toBeInstanceOf(
ConflictException,
);
expect(repository.create).not.toHaveBeenCalled();
});
it('findByUsername delegates to repository', async () => {
repository.findByUsername.mockResolvedValue(sampleUser);
await expect(service.findByUsername('Alice')).resolves.toEqual(sampleUser);
});
});
+28
View File
@@ -0,0 +1,28 @@
import { ConflictException, Injectable } from '@nestjs/common';
import { UsersRepository } from './users.repository';
import type { User } from './user';
@Injectable()
export class UsersService {
constructor(private readonly usersRepository: UsersRepository) {}
async findById(id: string): Promise<User | null> {
return this.usersRepository.findById(id);
}
async findByUsername(username: string): Promise<User | null> {
return this.usersRepository.findByUsername(username);
}
async create(username: string, passwordHash: string): Promise<User> {
const normalized = username.toLowerCase();
const existing = await this.usersRepository.findByUsername(normalized);
if (existing) {
throw new ConflictException('Username already registered');
}
return this.usersRepository.create({
username: normalized,
passwordHash,
});
}
}
+5
View File
@@ -3,6 +3,7 @@ import { INestApplication } from '@nestjs/common';
import request from 'supertest'; import request from 'supertest';
import { App } from 'supertest/types'; import { App } from 'supertest/types';
import { AppModule } from './../src/app.module'; import { AppModule } from './../src/app.module';
import { configureApp } from '../src/common/configure-app';
describe('AppController (e2e)', () => { describe('AppController (e2e)', () => {
let app: INestApplication<App>; let app: INestApplication<App>;
@@ -13,6 +14,10 @@ describe('AppController (e2e)', () => {
}).compile(); }).compile();
app = moduleFixture.createNestApplication(); app = moduleFixture.createNestApplication();
configureApp(app, {
NODE_ENV: 'test',
SWAGGER_ENABLED: 'false',
});
await app.init(); await app.init();
}); });
+106
View File
@@ -0,0 +1,106 @@
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from '../src/app.module';
import { configureApp } from '../src/common/configure-app';
describe('Auth (e2e)', () => {
let app: INestApplication<App>;
const username = `user_${Date.now()}`;
const password = 'password123';
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
configureApp(app, {
NODE_ENV: 'test',
SWAGGER_ENABLED: 'false',
});
await app.init();
});
afterAll(async () => {
await app.close();
});
it('GET / remains public', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
it('GET /auth/me without token returns 401', () => {
return request(app.getHttpServer()).get('/auth/me').expect(401);
});
it('register → me → refresh → revoke → me 401', async () => {
const register = await request(app.getHttpServer())
.post('/auth/register')
.send({ username, password })
.expect(201);
const { accessToken, refreshToken } = register.body as {
accessToken: string;
refreshToken: string;
};
expect(accessToken).toBeDefined();
expect(refreshToken).toHaveLength(64);
expect(Object.keys(register.body).sort()).toEqual([
'accessToken',
'refreshToken',
]);
const me = await request(app.getHttpServer())
.get('/auth/me')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
expect(me.body).toMatchObject({ username: username.toLowerCase() });
const refreshed = await request(app.getHttpServer())
.post('/auth/refresh')
.send({ refreshToken })
.expect(200);
const { accessToken: nextAccess, refreshToken: nextRefresh } =
refreshed.body as {
accessToken: string;
refreshToken: string;
};
await request(app.getHttpServer())
.get('/auth/me')
.set('Authorization', `Bearer ${nextAccess}`)
.expect(200);
await request(app.getHttpServer())
.post('/auth/revoke')
.send({ refreshToken: nextRefresh })
.expect(204);
await request(app.getHttpServer())
.get('/auth/me')
.set('Authorization', `Bearer ${nextAccess}`)
.expect(401);
});
it('login rejects invalid credentials', async () => {
await request(app.getHttpServer())
.post('/auth/login')
.send({ username, password: 'wrongpass' })
.expect(401);
});
it('duplicate register returns 409', async () => {
await request(app.getHttpServer())
.post('/auth/register')
.send({ username, password })
.expect(409);
});
});
+78
View File
@@ -0,0 +1,78 @@
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from '../src/app.module';
import { configureApp } from '../src/common/configure-app';
describe('Swagger (e2e)', () => {
describe('when enabled', () => {
let app: INestApplication<App>;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
configureApp(app, {
NODE_ENV: 'test',
SWAGGER_ENABLED: 'true',
});
await app.init();
});
afterAll(async () => {
await app.close();
});
it('GET /docs returns HTML UI', async () => {
const res = await request(app.getHttpServer()).get('/docs').expect(200);
expect(res.text).toContain('Swagger UI');
});
it('GET /docs-json includes auth paths', async () => {
const res = await request(app.getHttpServer())
.get('/docs-json')
.expect(200);
const body = res.body as {
paths: Record<string, unknown>;
components: { securitySchemes: Record<string, unknown> };
};
expect(body.paths['/auth/login']).toBeDefined();
expect(body.paths['/auth/me']).toBeDefined();
expect(body.paths['/auth/register']).toBeDefined();
expect(body.components.securitySchemes['access-token']).toBeDefined();
});
});
describe('when production and not explicitly enabled', () => {
let app: INestApplication<App>;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
configureApp(app, {
NODE_ENV: 'production',
});
await app.init();
});
afterAll(async () => {
await app.close();
});
it('GET /docs returns 404', () => {
return request(app.getHttpServer()).get('/docs').expect(404);
});
it('GET /docs-json returns 404', () => {
return request(app.getHttpServer()).get('/docs-json').expect(404);
});
});
});
File diff suppressed because one or more lines are too long
+5 -5
View File
@@ -1,11 +1,10 @@
{ {
"compilerOptions": { "compilerOptions": {
"module": "nodenext", "module": "commonjs",
"moduleResolution": "nodenext", "moduleResolution": "bundler",
"resolvePackageJsonExports": true,
"esModuleInterop": true, "esModuleInterop": true,
"isolatedModules": true, "isolatedModules": true,
"declaration": true, "declaration": false,
"removeComments": true, "removeComments": true,
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"experimentalDecorators": true, "experimentalDecorators": true,
@@ -13,6 +12,7 @@
"target": "ES2023", "target": "ES2023",
"sourceMap": true, "sourceMap": true,
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src",
"types": ["node", "jest"], "types": ["node", "jest"],
"incremental": true, "incremental": true,
"skipLibCheck": true, "skipLibCheck": true,
@@ -22,6 +22,6 @@
"strictBindCallApply": false, "strictBindCallApply": false,
"noFallthroughCasesInSwitch": false "noFallthroughCasesInSwitch": false
}, },
"include": ["src/**/*", "test/**/*"], "include": ["src/**/*"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }
File diff suppressed because one or more lines are too long