feat: add authorization
This commit is contained in:
@@ -1,59 +0,0 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { DocumentBuilder, OpenAPIObject, SwaggerModule } from '@nestjs/swagger';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
describe('AppController', () => {
|
||||
let appController: AppController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
|
||||
appController = app.get<AppController>(AppController);
|
||||
});
|
||||
|
||||
it('documents the safe internal error response', async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
const app: INestApplication = module.createNestApplication();
|
||||
app.setGlobalPrefix('api/v1');
|
||||
|
||||
const document: OpenAPIObject = SwaggerModule.createDocument(
|
||||
app,
|
||||
new DocumentBuilder().build(),
|
||||
);
|
||||
|
||||
expect(document.paths['/api/v1']?.get?.responses['500']).toEqual({
|
||||
description: 'Internal server error',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/SafeErrorResponseDto' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(document.paths['/api/v1']?.get?.responses['200']).toEqual(
|
||||
expect.objectContaining({
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { type: 'string', example: 'Hello World!' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe('root', () => {
|
||||
it('should return "Hello World!"', () => {
|
||||
expect(appController.getHello()).toBe('Hello World!');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiOkResponse } from '@nestjs/swagger';
|
||||
import { ApiSafeErrorResponse } from './common/decorators/api-safe-error-response.decorator';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOkResponse({
|
||||
schema: { type: 'string', example: 'Hello World!' },
|
||||
})
|
||||
@ApiSafeErrorResponse()
|
||||
getHello(): string {
|
||||
return this.appService.getHello();
|
||||
}
|
||||
}
|
||||
+2
-4
@@ -1,7 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { validateEnv } from './config/env.validation';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
|
||||
@@ -12,8 +11,7 @@ import { PrismaModule } from './prisma/prisma.module';
|
||||
validate: validateEnv,
|
||||
}),
|
||||
PrismaModule,
|
||||
AuthModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiNoContentResponse,
|
||||
ApiOkResponse,
|
||||
ApiProperty,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import type { Request, Response } from 'express';
|
||||
import { CredentialsDto } from './auth.dto';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AccessPayload } from './auth.types';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
|
||||
const REFRESH_COOKIE = 'refresh_token';
|
||||
const COOKIE_PATH = '/api/v1/auth';
|
||||
|
||||
class UserDto {
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
|
||||
@ApiProperty()
|
||||
email: string;
|
||||
}
|
||||
|
||||
class AuthenticationDto {
|
||||
@ApiProperty()
|
||||
accessToken: string;
|
||||
|
||||
@ApiProperty({ type: UserDto })
|
||||
user: UserDto;
|
||||
}
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly auth: AuthService) {}
|
||||
|
||||
@Post('register')
|
||||
@ApiOkResponse({ type: AuthenticationDto })
|
||||
async register(
|
||||
@Body() credentials: CredentialsDto,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
return this.respond(
|
||||
response,
|
||||
await this.auth.register(credentials.email, credentials.password),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@ApiOkResponse({ type: AuthenticationDto })
|
||||
async login(
|
||||
@Body() credentials: CredentialsDto,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
return this.respond(
|
||||
response,
|
||||
await this.auth.login(credentials.email, credentials.password),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@ApiOkResponse({ type: AuthenticationDto })
|
||||
async refresh(
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
try {
|
||||
const token = this.refreshToken(request);
|
||||
if (!token) throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||
return this.respond(response, await this.auth.refresh(token));
|
||||
} catch (error) {
|
||||
this.clearCookie(response);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(204)
|
||||
@ApiNoContentResponse()
|
||||
async logout(
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
): Promise<void> {
|
||||
await this.auth.logout(this.refreshToken(request));
|
||||
this.clearCookie(response);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOkResponse({ type: UserDto })
|
||||
async me(@Req() request: Request & { user: AccessPayload }) {
|
||||
const user = await this.auth.userById(request.user.sub);
|
||||
return this.auth.toUser(user);
|
||||
}
|
||||
|
||||
private respond(
|
||||
response: Response,
|
||||
result: {
|
||||
user: { id: string; email: string };
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
},
|
||||
) {
|
||||
response.cookie(REFRESH_COOKIE, result.refreshToken, this.cookieOptions());
|
||||
return { user: result.user, accessToken: result.accessToken };
|
||||
}
|
||||
|
||||
private clearCookie(response: Response): void {
|
||||
response.clearCookie(REFRESH_COOKIE, this.cookieOptions());
|
||||
}
|
||||
|
||||
private refreshToken(request: Request): string | undefined {
|
||||
const token: unknown = request.cookies?.[REFRESH_COOKIE];
|
||||
return typeof token === 'string' ? token : undefined;
|
||||
}
|
||||
|
||||
private cookieOptions() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax' as const,
|
||||
secure: process.env['NODE_ENV'] !== 'development',
|
||||
path: COOKIE_PATH,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
IsString,
|
||||
MinLength,
|
||||
Validate,
|
||||
ValidatorConstraint,
|
||||
} from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
@ValidatorConstraint({ name: 'email' })
|
||||
class EmailConstraint {
|
||||
validate(value: unknown): boolean {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length <= 254 &&
|
||||
value.split('@').length === 2 &&
|
||||
!value
|
||||
.split('')
|
||||
.some(
|
||||
(character) =>
|
||||
/\s/.test(character) ||
|
||||
character.charCodeAt(0) < 32 ||
|
||||
character.charCodeAt(0) === 127,
|
||||
) &&
|
||||
value.split('@').every(Boolean)
|
||||
);
|
||||
}
|
||||
defaultMessage(): string {
|
||||
return 'must be a valid email address';
|
||||
}
|
||||
}
|
||||
|
||||
@ValidatorConstraint({ name: 'passwordBytes' })
|
||||
class PasswordBytesConstraint {
|
||||
validate(value: unknown): boolean {
|
||||
return typeof value === 'string' && Buffer.byteLength(value, 'utf8') <= 72;
|
||||
}
|
||||
defaultMessage(): string {
|
||||
return 'must not exceed 72 UTF-8 bytes';
|
||||
}
|
||||
}
|
||||
|
||||
export class CredentialsDto {
|
||||
@ApiProperty({ example: 'user@example.com' })
|
||||
@IsString()
|
||||
@Transform(({ value }: { value: unknown }) =>
|
||||
typeof value === 'string' ? value.trim().toLowerCase() : value,
|
||||
)
|
||||
@Validate(EmailConstraint)
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ minLength: 12, maxLength: 72, example: 'a secure password' })
|
||||
@IsString()
|
||||
@MinLength(12)
|
||||
@Validate(PasswordBytesConstraint)
|
||||
password: string;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { Environment } from '../config/env.validation';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService<Environment, true>) => ({
|
||||
secret: config.getOrThrow('JWT_SECRET'),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy, JwtAuthGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,184 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
import { Session, User } from '../../generated/prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuthUser, RefreshPayload } from './auth.types';
|
||||
|
||||
const ACCESS_TTL = '15m';
|
||||
const REFRESH_TTL = '7d';
|
||||
const DUMMY_PASSWORD_HASH =
|
||||
'$2b$12$Z49BwJsyiUqY0ZozFzt9IeYvjhV4kbeLw9KdxOguT5bfhA1RvPCx6';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
) {}
|
||||
|
||||
async register(
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
||||
const normalizedEmail = this.normalizeEmail(email);
|
||||
const existing = await this.prisma.user.findUnique({
|
||||
where: { email: normalizedEmail },
|
||||
});
|
||||
if (existing)
|
||||
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' });
|
||||
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
email: normalizedEmail,
|
||||
passwordHash: await bcrypt.hash(password, 12),
|
||||
},
|
||||
});
|
||||
return this.createSession(user);
|
||||
}
|
||||
|
||||
async login(
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { email: this.normalizeEmail(email) },
|
||||
});
|
||||
const valid = await bcrypt.compare(
|
||||
password,
|
||||
user?.passwordHash ?? DUMMY_PASSWORD_HASH,
|
||||
);
|
||||
if (!user || !valid)
|
||||
throw new UnauthorizedException({ code: 'INVALID_CREDENTIALS' });
|
||||
return this.createSession(user);
|
||||
}
|
||||
|
||||
async refresh(
|
||||
token: string,
|
||||
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
||||
let payload: RefreshPayload;
|
||||
try {
|
||||
payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
} catch {
|
||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
if (payload.type !== 'refresh')
|
||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||
const session = await this.prisma.session.findUnique({
|
||||
where: { id: payload.sid },
|
||||
include: { user: true },
|
||||
});
|
||||
if (
|
||||
!session ||
|
||||
session.userId !== payload.sub ||
|
||||
session.revokedAt ||
|
||||
session.expiresAt <= new Date()
|
||||
) {
|
||||
if (session && !session.revokedAt)
|
||||
await this.prisma.session.delete({ where: { id: session.id } });
|
||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
if (session.refreshTokenHash !== this.hash(token)) {
|
||||
await this.prisma.session.update({
|
||||
where: { id: session.id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
return this.rotateSession(session);
|
||||
}
|
||||
|
||||
async logout(token?: string): Promise<void> {
|
||||
if (!token) return;
|
||||
try {
|
||||
const payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
if (payload.type !== 'refresh') return;
|
||||
const session = await this.prisma.session.findUnique({
|
||||
where: { id: payload.sid },
|
||||
});
|
||||
if (
|
||||
session &&
|
||||
!session.revokedAt &&
|
||||
session.refreshTokenHash === this.hash(token)
|
||||
) {
|
||||
await this.prisma.session.update({
|
||||
where: { id: session.id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Logout is intentionally idempotent.
|
||||
}
|
||||
}
|
||||
|
||||
toUser(user: User): AuthUser {
|
||||
return { id: user.id, email: user.email };
|
||||
}
|
||||
|
||||
async userById(id: string): Promise<User> {
|
||||
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||
if (!user) throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||
return user;
|
||||
}
|
||||
|
||||
private async createSession(
|
||||
user: User,
|
||||
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
||||
const session = await this.prisma.session.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
userId: user.id,
|
||||
refreshTokenHash: '',
|
||||
expiresAt: new Date(),
|
||||
},
|
||||
});
|
||||
return this.rotateSession({ ...session, user });
|
||||
}
|
||||
|
||||
private async rotateSession(
|
||||
session: Session & { user: User },
|
||||
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
||||
const refreshToken = await this.jwt.signAsync(
|
||||
{
|
||||
sub: session.userId,
|
||||
sid: session.id,
|
||||
type: 'refresh',
|
||||
jti: randomUUID(),
|
||||
},
|
||||
{ expiresIn: REFRESH_TTL, algorithm: 'HS256' },
|
||||
);
|
||||
await this.prisma.session.update({
|
||||
where: { id: session.id },
|
||||
data: {
|
||||
refreshTokenHash: this.hash(refreshToken),
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
return {
|
||||
user: this.toUser(session.user),
|
||||
accessToken: await this.jwt.signAsync(
|
||||
{ sub: session.userId, sid: session.id, type: 'access' },
|
||||
{ expiresIn: ACCESS_TTL, algorithm: 'HS256' },
|
||||
),
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeEmail(email: string): string {
|
||||
return email;
|
||||
}
|
||||
|
||||
private hash(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface AccessPayload {
|
||||
sub: string;
|
||||
sid: string;
|
||||
type: 'access';
|
||||
}
|
||||
|
||||
export interface RefreshPayload {
|
||||
sub: string;
|
||||
sid: string;
|
||||
type: 'refresh';
|
||||
jti: string;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { Environment } from '../config/env.validation';
|
||||
import { AccessPayload } from './auth.types';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(config: ConfigService<Environment, true>) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: config.getOrThrow('JWT_SECRET'),
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
}
|
||||
|
||||
validate(payload: AccessPayload): AccessPayload {
|
||||
if (payload.type !== 'access')
|
||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
@@ -4,25 +4,22 @@ import { HttpExceptionFilter } from './http-exception.filter';
|
||||
describe('HttpExceptionFilter', () => {
|
||||
it('does not expose internal error details', () => {
|
||||
const status = jest.fn().mockReturnThis();
|
||||
const type = jest.fn().mockReturnThis();
|
||||
const json = jest.fn();
|
||||
const filter = new HttpExceptionFilter();
|
||||
const host = {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({
|
||||
path: '/api/v1/health',
|
||||
url: '/api/v1/health?access_token=secret',
|
||||
}),
|
||||
getResponse: () => ({ status, json }),
|
||||
getResponse: () => ({ status, type, json }),
|
||||
}),
|
||||
} as unknown as ArgumentsHost;
|
||||
|
||||
filter.catch(new Error('database password: secret'), host);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(500);
|
||||
expect(type).toHaveBeenCalledWith('application/problem+json');
|
||||
expect(json).toHaveBeenCalledWith({
|
||||
statusCode: 500,
|
||||
message: 'Internal server error',
|
||||
path: '/api/v1/health',
|
||||
status: 500,
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,24 +4,65 @@ import {
|
||||
ExceptionFilter,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
ValidationError,
|
||||
} from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
import type { Response } from 'express';
|
||||
|
||||
@Catch()
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const context = host.switchToHttp();
|
||||
const response = context.getResponse<Response>();
|
||||
const request = context.getRequest<Request>();
|
||||
const status =
|
||||
exception instanceof HttpException
|
||||
? exception.getStatus()
|
||||
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
response.status(status).json({
|
||||
statusCode: status,
|
||||
message: status === 500 ? 'Internal server error' : 'Request failed',
|
||||
path: request.path,
|
||||
});
|
||||
const payload =
|
||||
exception instanceof HttpException ? exception.getResponse() : undefined;
|
||||
const isValidation = Array.isArray(
|
||||
(payload as { message?: unknown })?.message,
|
||||
);
|
||||
response
|
||||
.status(status)
|
||||
.type('application/problem+json')
|
||||
.json({
|
||||
status,
|
||||
code: isValidation ? 'VALIDATION_ERROR' : this.codeFor(status, payload),
|
||||
...(isValidation
|
||||
? {
|
||||
errors: this.validationErrors(
|
||||
(payload as { message: ValidationError[] }).message,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
private codeFor(status: number, payload: unknown): string {
|
||||
if (typeof payload === 'object' && payload !== null && 'code' in payload) {
|
||||
const code = (payload as { code?: unknown }).code;
|
||||
if (typeof code === 'string') return code;
|
||||
}
|
||||
return (
|
||||
{
|
||||
400: 'BAD_REQUEST',
|
||||
401: 'UNAUTHORIZED',
|
||||
404: 'NOT_FOUND',
|
||||
415: 'UNSUPPORTED_MEDIA_TYPE',
|
||||
500: 'INTERNAL_SERVER_ERROR',
|
||||
}[status] ?? 'INTERNAL_SERVER_ERROR'
|
||||
);
|
||||
}
|
||||
|
||||
private validationErrors(
|
||||
errors: ValidationError[],
|
||||
): { pointer: string; detail: string }[] {
|
||||
return errors.flatMap((error) =>
|
||||
Object.values(error.constraints ?? {}).map((detail) => ({
|
||||
pointer: `#/${error.property}`,
|
||||
detail,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,13 @@ describe('validateEnv', () => {
|
||||
validateEnv({
|
||||
DATABASE_URL: 'file:./data.db',
|
||||
FRONTEND_ORIGIN: 'http://localhost:5173/',
|
||||
JWT_SECRET: 'a-secret-that-is-at-least-thirty-two-characters',
|
||||
}),
|
||||
).toEqual({
|
||||
DATABASE_URL: 'file:./data.db',
|
||||
FRONTEND_ORIGIN: 'http://localhost:5173',
|
||||
PORT: 3000,
|
||||
JWT_SECRET: 'a-secret-that-is-at-least-thirty-two-characters',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +34,7 @@ describe('validateEnv', () => {
|
||||
validateEnv({
|
||||
DATABASE_URL: 'file:./data.db',
|
||||
FRONTEND_ORIGIN: origin,
|
||||
JWT_SECRET: 'a-secret-that-is-at-least-thirty-two-characters',
|
||||
}),
|
||||
).toThrow('FRONTEND_ORIGIN must be a valid HTTP origin');
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface Environment {
|
||||
DATABASE_URL: string;
|
||||
FRONTEND_ORIGIN: string;
|
||||
JWT_SECRET: string;
|
||||
PORT: number;
|
||||
}
|
||||
|
||||
@@ -8,6 +9,7 @@ export function validateEnv(config: Record<string, unknown>): Environment {
|
||||
const databaseUrl = config['DATABASE_URL'];
|
||||
const frontendOrigin = config['FRONTEND_ORIGIN'];
|
||||
const port = config['PORT'] ?? 3000;
|
||||
const jwtSecret = config['JWT_SECRET'];
|
||||
|
||||
if (typeof databaseUrl !== 'string' || !databaseUrl.startsWith('file:')) {
|
||||
throw new Error('DATABASE_URL must be a SQLite file URL');
|
||||
@@ -38,9 +40,14 @@ export function validateEnv(config: Record<string, unknown>): Environment {
|
||||
throw new Error('PORT must be a valid port number');
|
||||
}
|
||||
|
||||
if (typeof jwtSecret !== 'string' || jwtSecret.length < 32) {
|
||||
throw new Error('JWT_SECRET must be at least 32 characters long');
|
||||
}
|
||||
|
||||
return {
|
||||
DATABASE_URL: databaseUrl,
|
||||
FRONTEND_ORIGIN: parsedOrigin.origin,
|
||||
JWT_SECRET: jwtSecret,
|
||||
PORT: parsedPort,
|
||||
};
|
||||
}
|
||||
|
||||
+5
-1
@@ -1,4 +1,6 @@
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { AppModule } from './app.module';
|
||||
@@ -11,8 +13,10 @@ async function bootstrap() {
|
||||
const config = app.get<ConfigService<Environment, true>>(ConfigService);
|
||||
const frontendOrigin = config.getOrThrow<string>('FRONTEND_ORIGIN');
|
||||
|
||||
app.enableCors({ origin: frontendOrigin });
|
||||
app.enableCors({ origin: frontendOrigin, credentials: true });
|
||||
app.setGlobalPrefix('api/v1');
|
||||
app.use(cookieParser());
|
||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
|
||||
if (shouldExposeSwagger(process.env['NODE_ENV'])) {
|
||||
|
||||
Reference in New Issue
Block a user