From e8d3a248e9b33d850f4cb145d0f8cec1b0cf85be Mon Sep 17 00:00:00 2001 From: Demian Date: Sat, 11 Apr 2026 18:24:29 -0400 Subject: [PATCH 1/9] feat: switch auth tokens to httpOnly cookies Moves access and refresh tokens out of the response body and into httpOnly cookies to prevent XSS-based token theft. Changes: - Register cookie-parser middleware in main.ts - CORS configured with credentials: true for cross-origin cookie support - JwtStrategy extracts access_token from req.cookies - login sets access_token (15m) and refresh_token (7d) as httpOnly cookies - refresh rotates both cookies; logout clears them - RefreshTokenAuthGuard rewritten as CanActivate reading refresh_token cookie - Remove passport-http-bearer dependency (no longer needed) Closes #93 --- backend/package.json | 4 +- backend/src/main.ts | 31 +++--- backend/src/modules/auth/auth.controller.ts | 73 ++++++++++--- backend/src/modules/auth/auth.module.ts | 3 +- backend/src/modules/auth/auth.service.ts | 15 ++- backend/src/modules/auth/jwt.strategy.ts | 7 +- .../modules/auth/refresh-token-auth.guard.ts | 21 +++- .../modules/auth/refresh-token.strategy.ts | 20 ---- pnpm-lock.yaml | 102 +++++------------- 9 files changed, 136 insertions(+), 140 deletions(-) delete mode 100644 backend/src/modules/auth/refresh-token.strategy.ts diff --git a/backend/package.json b/backend/package.json index 76649e9..2beee56 100644 --- a/backend/package.json +++ b/backend/package.json @@ -50,10 +50,10 @@ "cache-manager-redis-yet": "^5.1.5", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", + "cookie-parser": "^1.4.7", "dotenv": "^16.4.5", "figlet": "^1.8.0", "passport": "^0.7.0", - "passport-http-bearer": "^1.0.1", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", "pg": "^8.11.5", @@ -67,11 +67,11 @@ "@nestjs/cli": "^10.0.0", "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.3.9", + "@types/cookie-parser": "^1.4.8", "@types/express": "^4.17.21", "@types/figlet": "^1.7.0", "@types/jest": "^29.5.12", "@types/node": "^20.12.12", - "@types/passport-http-bearer": "^1.0.42", "@types/passport-jwt": "^4.0.1", "@types/passport-local": "^1.0.38", "@types/supertest": "^6.0.0", diff --git a/backend/src/main.ts b/backend/src/main.ts index 04c2f9d..7951b2c 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -2,9 +2,11 @@ import 'reflect-metadata'; import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { Logger, ValidationPipe } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import * as figlet from 'figlet'; import * as dotenv from 'dotenv'; +import cookieParser from 'cookie-parser'; import { HttpExceptionFilter } from './common/filters/http-exception.filter'; dotenv.config(); @@ -13,14 +15,23 @@ async function bootstrap() { const app = await NestFactory.create(AppModule); // Application configuration - const port = process.env.PORT || 3001; - const appName = process.env.APP_NAME || 'STATION BACKEND'; + const configService = app.get(ConfigService); + const port = configService.get('PORT') || 3001; + const appName = configService.get('APP_NAME') || 'STATION BACKEND'; // ASCII Art for Application Name console.log(figlet.textSync(appName, { horizontalLayout: 'full' })); - // Enable CORS (if needed for APIs) - app.enableCors(); + // Cookie parser — must be registered before guards that read cookies + app.use(cookieParser()); + + // CORS — allow credentials so httpOnly cookies are sent cross-origin + app.enableCors({ + origin: + configService.get('ALLOWED_ORIGIN') || 'http://localhost:5173', + credentials: true, + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + }); // Global Validation Pipe app.useGlobalPipes( @@ -62,16 +73,6 @@ async function bootstrap() { }, 'access-token', ) - .addBearerAuth( - { - type: 'http', - scheme: 'bearer', - name: 'Refresh Token', - description: 'Enter refresh token', - in: 'header', - }, - 'refresh-token', - ) .build(); const document = SwaggerModule.createDocument(app, config); @@ -81,7 +82,7 @@ async function bootstrap() { // Log application startup information await app.listen(port); Logger.log( - `🚀 Application '${appName}' is running on: http://localhost:${port}`, + `Application '${appName}' is running on: http://localhost:${port}`, 'Bootstrap', ); if (process.env.NODE_ENV !== 'production') { diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index 080e06a..4e5db24 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -1,4 +1,11 @@ -import { Controller, Post, UseGuards, Request, Body } from '@nestjs/common'; +import { + Controller, + Post, + UseGuards, + Request, + Body, + Res, +} from '@nestjs/common'; import { ApiTags, ApiOperation, @@ -11,7 +18,7 @@ import { LocalAuthGuard } from './local-auth.guard'; import { JwtAuthGuard } from './jwt-auth.guard'; import { RefreshTokenAuthGuard } from './refresh-token-auth.guard'; import { UserDto } from '../users/dto/user.dto'; -import { Request as ExpressRequest } from 'express'; +import { Request as ExpressRequest, Response } from 'express'; import { ChangePasswordDto, ForgotPasswordDto, @@ -37,8 +44,29 @@ export class AuthController { @ApiResponse({ status: 401, description: 'Invalid credentials' }) @UseGuards(LocalAuthGuard) @Post('login') - async login(@Request() req: ExpressRequest) { - return this.authService.login(req.user); + async login( + @Request() req: ExpressRequest, + @Res({ passthrough: true }) res: Response, + ) { + const tokens = await this.authService.login( + req.user as Parameters[0], + ); + const isProduction = process.env.NODE_ENV === 'production'; + const cookieBase = { + httpOnly: true, + secure: isProduction, + sameSite: 'lax' as const, + path: '/', + }; + res.cookie('access_token', tokens.accessToken, { + ...cookieBase, + maxAge: 15 * 60 * 1000, + }); + res.cookie('refresh_token', tokens.refreshToken, { + ...cookieBase, + maxAge: 7 * 24 * 60 * 60 * 1000, + }); + return { message: 'Login successful' }; } @ApiOperation({ summary: 'Register new user' }) @@ -50,26 +78,45 @@ export class AuthController { return this.authService.register(userDto); } - @ApiOperation({ summary: 'Refresh access token using refresh token' }) - @ApiBearerAuth('refresh-token') + @ApiOperation({ summary: 'Refresh access token using refresh token cookie' }) @ApiResponse({ status: 200, description: 'Tokens refreshed successfully' }) @ApiResponse({ status: 401, description: 'Invalid or expired refresh token' }) @UseGuards(RefreshTokenAuthGuard) @Post('refresh') - async refresh(@Request() req: any) { - const refreshToken = req.user.refreshToken; - return this.authService.refreshAccessToken(refreshToken); + async refresh( + @Request() req: any, + @Res({ passthrough: true }) res: Response, + ) { + const tokens = await this.authService.refreshAccessToken( + req.user.refreshToken, + ); + const isProduction = process.env.NODE_ENV === 'production'; + const cookieBase = { + httpOnly: true, + secure: isProduction, + sameSite: 'lax' as const, + path: '/', + }; + res.cookie('access_token', tokens.accessToken, { + ...cookieBase, + maxAge: 15 * 60 * 1000, + }); + res.cookie('refresh_token', tokens.refreshToken, { + ...cookieBase, + maxAge: 7 * 24 * 60 * 60 * 1000, + }); + return { message: 'Tokens refreshed successfully' }; } @ApiOperation({ summary: 'Logout user and revoke refresh token' }) - @ApiBearerAuth('refresh-token') @ApiResponse({ status: 200, description: 'Successfully logged out' }) @ApiResponse({ status: 401, description: 'Invalid refresh token' }) @UseGuards(RefreshTokenAuthGuard) @Post('logout') - async logout(@Request() req: any) { - const refreshToken = req.user.refreshToken; - await this.authService.revokeRefreshToken(refreshToken); + async logout(@Request() req: any, @Res({ passthrough: true }) res: Response) { + await this.authService.revokeRefreshToken(req.user.refreshToken); + res.clearCookie('access_token', { path: '/' }); + res.clearCookie('refresh_token', { path: '/' }); return { message: 'Logged out successfully' }; } diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts index fb2778c..d20eb70 100644 --- a/backend/src/modules/auth/auth.module.ts +++ b/backend/src/modules/auth/auth.module.ts @@ -6,7 +6,6 @@ import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { LocalStrategy } from './local.strategy'; import { JwtStrategy } from './jwt.strategy'; -import { RefreshTokenStrategy } from './refresh-token.strategy'; import { UsersModule } from '../users/users.module'; import { RefreshToken } from './refresh-token.entity'; import { PasswordReset } from './password-reset.entity'; @@ -27,7 +26,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; }), ], controllers: [AuthController], - providers: [AuthService, LocalStrategy, JwtStrategy, RefreshTokenStrategy], + providers: [AuthService, LocalStrategy, JwtStrategy], exports: [AuthService], }) export class AuthModule {} diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 2917db2..4efc02b 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -60,16 +60,13 @@ export class AuthService { } async login( - user: any, - ): Promise<{ access_token: string; refresh_token: string }> { + user: Omit, + ): Promise<{ accessToken: string; refreshToken: string }> { const payload = { username: user.username, sub: user.id }; const accessToken = this.jwtService.sign(payload); const refreshToken = await this.generateRefreshToken(user.id); - return { - access_token: accessToken, - refresh_token: refreshToken, - }; + return { accessToken, refreshToken }; } async register(userDto: UserDto): Promise> { @@ -100,7 +97,7 @@ export class AuthService { async refreshAccessToken( refreshToken: string, - ): Promise<{ access_token: string; refresh_token: string }> { + ): Promise<{ accessToken: string; refreshToken: string }> { // Find the refresh token const storedToken = await this.refreshTokenRepository.findOne({ where: { token: refreshToken }, @@ -132,8 +129,8 @@ export class AuthService { ); return { - access_token: newAccessToken, - refresh_token: newRefreshToken, + accessToken: newAccessToken, + refreshToken: newRefreshToken, }; } diff --git a/backend/src/modules/auth/jwt.strategy.ts b/backend/src/modules/auth/jwt.strategy.ts index 4e0e372..732be7e 100644 --- a/backend/src/modules/auth/jwt.strategy.ts +++ b/backend/src/modules/auth/jwt.strategy.ts @@ -2,18 +2,21 @@ import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { ConfigService } from '@nestjs/config'; +import { Request } from 'express'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { constructor(private configService: ConfigService) { super({ - jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + jwtFromRequest: ExtractJwt.fromExtractors([ + (req: Request) => req?.cookies?.access_token ?? null, + ]), ignoreExpiration: false, secretOrKey: configService.get('JWT_SECRET'), }); } - async validate(payload: any) { + async validate(payload: { sub: number; username: string }) { return { userId: payload.sub, username: payload.username }; } } diff --git a/backend/src/modules/auth/refresh-token-auth.guard.ts b/backend/src/modules/auth/refresh-token-auth.guard.ts index e2b8cf1..4e613f5 100644 --- a/backend/src/modules/auth/refresh-token-auth.guard.ts +++ b/backend/src/modules/auth/refresh-token-auth.guard.ts @@ -1,5 +1,20 @@ -import { Injectable } from '@nestjs/common'; -import { AuthGuard } from '@nestjs/passport'; +import { + Injectable, + CanActivate, + ExecutionContext, + UnauthorizedException, +} from '@nestjs/common'; +import { Request } from 'express'; @Injectable() -export class RefreshTokenAuthGuard extends AuthGuard('refresh-token') {} +export class RefreshTokenAuthGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const refreshToken = request.cookies?.refresh_token; + if (!refreshToken) { + throw new UnauthorizedException('No refresh token provided'); + } + request.user = { refreshToken }; + return true; + } +} diff --git a/backend/src/modules/auth/refresh-token.strategy.ts b/backend/src/modules/auth/refresh-token.strategy.ts deleted file mode 100644 index 4644859..0000000 --- a/backend/src/modules/auth/refresh-token.strategy.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { PassportStrategy } from '@nestjs/passport'; -import { Strategy } from 'passport-http-bearer'; -import { AuthService } from './auth.service'; - -@Injectable() -export class RefreshTokenStrategy extends PassportStrategy( - Strategy, - 'refresh-token', -) { - constructor(private authService: AuthService) { - super(); - } - - async validate(token: string) { - // The token is already extracted by passport-http-bearer - // We just need to return it so it's available in the request - return { refreshToken: token }; - } -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6c590f..662e1f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,6 +77,9 @@ importers: class-validator: specifier: ^0.14.2 version: 0.14.2 + cookie-parser: + specifier: ^1.4.7 + version: 1.4.7 dotenv: specifier: ^16.4.5 version: 16.6.1 @@ -86,9 +89,6 @@ importers: passport: specifier: ^0.7.0 version: 0.7.0 - passport-http-bearer: - specifier: ^1.0.1 - version: 1.0.1 passport-jwt: specifier: ^4.0.1 version: 4.0.1 @@ -123,6 +123,9 @@ importers: '@nestjs/testing': specifier: ^10.3.9 version: 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)(@nestjs/platform-express@10.4.20) + '@types/cookie-parser': + specifier: ^1.4.8 + version: 1.4.10(@types/express@4.17.25) '@types/express': specifier: ^4.17.21 version: 4.17.25 @@ -135,9 +138,6 @@ importers: '@types/node': specifier: ^20.12.12 version: 20.19.25 - '@types/passport-http-bearer': - specifier: ^1.0.42 - version: 1.0.42 '@types/passport-jwt': specifier: ^4.0.1 version: 4.0.1 @@ -1337,9 +1337,6 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} - '@types/accepts@1.3.7': - resolution: {integrity: sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==} - '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -1364,15 +1361,14 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} - '@types/content-disposition@0.5.9': - resolution: {integrity: sha512-8uYXI3Gw35MhiVYhG3s295oihrxRyytcRHjSjqnqZVDDy/xcGBRny7+Xj1Wgfhv5QzRtN2hB2dVRBUX9XW3UcQ==} + '@types/cookie-parser@1.4.10': + resolution: {integrity: sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==} + peerDependencies: + '@types/express': '*' '@types/cookiejar@2.1.5': resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} - '@types/cookies@0.9.2': - resolution: {integrity: sha512-1AvkDdZM2dbyFybL4fxpuNCaWyv//0AwsuUk2DWeXyM1/5ZKm6W3z6mQi24RZ4l2ucY+bkSHzbDVpySqPGuV8A==} - '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -1397,9 +1393,6 @@ packages: '@types/history@4.7.11': resolution: {integrity: sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==} - '@types/http-assert@1.5.6': - resolution: {integrity: sha512-TTEwmtjgVbYAzZYWyeHPrrtWnfVkm8tQkP8P21uQifPgMRgjrow3XDEYqucuC8SKZJT7pUnhU/JymvjggxO9vw==} - '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} @@ -1424,15 +1417,6 @@ packages: '@types/jsonwebtoken@9.0.5': resolution: {integrity: sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==} - '@types/keygrip@1.0.6': - resolution: {integrity: sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==} - - '@types/koa-compose@3.2.9': - resolution: {integrity: sha512-BroAZ9FTvPiCy0Pi8tjD1OfJ7bgU1gQf0eR6e1Vm+JJATy9eKOG3hQMFtMciMawiSOVnLMdmUOC46s7HBhSTsA==} - - '@types/koa@3.0.1': - resolution: {integrity: sha512-VkB6WJUQSe0zBpR+Q7/YIUESGp5wPHcaXr0xueU5W0EOUWtlSbblsl+Kl31lyRQ63nIILh0e/7gXjQ09JXJIHw==} - '@types/luxon@3.7.1': resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==} @@ -1451,9 +1435,6 @@ packages: '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} - '@types/passport-http-bearer@1.0.42': - resolution: {integrity: sha512-cGezyf9hy3Cth+zWS779FR9XYhIX/DExsVZURqcSeUU/nhj0Aw8PUhvCyfS35ScwOSd5AFiFhtfWmqHa/2aYZg==} - '@types/passport-jwt@4.0.1': resolution: {integrity: sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==} @@ -2061,6 +2042,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-parser@1.4.7: + resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==} + engines: {node: '>= 0.8.0'} + cookie-signature@1.0.6: resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} @@ -2068,6 +2053,10 @@ packages: resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} engines: {node: '>= 0.6'} + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + cookiejar@2.1.4: resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} @@ -3445,10 +3434,6 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} - passport-http-bearer@1.0.1: - resolution: {integrity: sha512-SELQM+dOTuMigr9yu8Wo4Fm3ciFfkMq5h/ZQ8ffi4ELgZrX1xh9PlglqZdcUZ1upzJD/whVyt+YWF62s3U6Ipw==} - engines: {node: '>= 0.4.0'} - passport-jwt@4.0.1: resolution: {integrity: sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==} @@ -5668,10 +5653,6 @@ snapshots: '@tsconfig/node16@1.0.4': {} - '@types/accepts@1.3.7': - dependencies: - '@types/node': 20.19.25 - '@types/aria-query@5.0.4': {} '@types/babel__core@7.20.5': @@ -5708,16 +5689,11 @@ snapshots: dependencies: '@types/node': 20.19.25 - '@types/content-disposition@0.5.9': {} - - '@types/cookiejar@2.1.5': {} - - '@types/cookies@0.9.2': + '@types/cookie-parser@1.4.10(@types/express@4.17.25)': dependencies: - '@types/connect': 3.4.38 '@types/express': 4.17.25 - '@types/keygrip': 1.0.6 - '@types/node': 20.19.25 + + '@types/cookiejar@2.1.5': {} '@types/eslint-scope@3.7.7': dependencies: @@ -5753,8 +5729,6 @@ snapshots: '@types/history@4.7.11': {} - '@types/http-assert@1.5.6': {} - '@types/http-errors@2.0.5': {} '@types/istanbul-lib-coverage@2.0.6': {} @@ -5783,23 +5757,6 @@ snapshots: dependencies: '@types/node': 20.19.25 - '@types/keygrip@1.0.6': {} - - '@types/koa-compose@3.2.9': - dependencies: - '@types/koa': 3.0.1 - - '@types/koa@3.0.1': - dependencies: - '@types/accepts': 1.3.7 - '@types/content-disposition': 0.5.9 - '@types/cookies': 0.9.2 - '@types/http-assert': 1.5.6 - '@types/http-errors': 2.0.5 - '@types/keygrip': 1.0.6 - '@types/koa-compose': 3.2.9 - '@types/node': 20.19.25 - '@types/luxon@3.7.1': {} '@types/methods@1.1.4': {} @@ -5814,12 +5771,6 @@ snapshots: '@types/parse-json@4.0.2': {} - '@types/passport-http-bearer@1.0.42': - dependencies: - '@types/express': 4.17.25 - '@types/koa': 3.0.1 - '@types/passport': 1.0.17 - '@types/passport-jwt@4.0.1': dependencies: '@types/jsonwebtoken': 9.0.10 @@ -6541,10 +6492,17 @@ snapshots: convert-source-map@2.0.0: {} + cookie-parser@1.4.7: + dependencies: + cookie: 0.7.2 + cookie-signature: 1.0.6 + cookie-signature@1.0.6: {} cookie@0.7.1: {} + cookie@0.7.2: {} + cookiejar@2.1.4: {} core-util-is@1.0.3: {} @@ -8183,10 +8141,6 @@ snapshots: parseurl@1.3.3: {} - passport-http-bearer@1.0.1: - dependencies: - passport-strategy: 1.0.0 - passport-jwt@4.0.1: dependencies: jsonwebtoken: 9.0.2 From 4fdd24daeaaa14774b044db62423280e27976988 Mon Sep 17 00:00:00 2001 From: Demian Date: Sat, 11 Apr 2026 18:34:09 -0400 Subject: [PATCH 2/9] feat: add ConfigModule startup validation with Joi schema Adds environment variable validation at boot time so misconfigured deployments fail immediately with a clear error rather than silently booting and crashing at runtime on first usage. Changes: - Install joi - Add src/config/env.validation.ts with Joi schema covering all vars - JWT_SECRET and JWT_REFRESH_SECRET required, minimum 32 chars enforced - DATABASE_* credentials required; connection config defaults provided - REDIS, CORS, and FRONTEND_URL have sensible local defaults - UEX sync vars all optional with defaults (service degrades gracefully) - validationOptions.abortEarly: false reports all missing vars at once - Update .env.example to document every variable in the schema - Update .env.test to satisfy the new required JWT_REFRESH_SECRET Closes #97 --- backend/.env.example | 27 ++++++++---- backend/.env.test | 3 +- backend/package.json | 1 + backend/src/app.module.ts | 3 ++ backend/src/config/env.validation.ts | 49 ++++++++++++++++++++++ pnpm-lock.yaml | 61 +++++++++++++++++++++++++++- 6 files changed, 133 insertions(+), 11 deletions(-) create mode 100644 backend/src/config/env.validation.ts diff --git a/backend/.env.example b/backend/.env.example index 9e03ac1..b00d799 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,23 +1,32 @@ -# Database Configuration +# ─── Application ─────────────────────────────────────────────────────────────── +NODE_ENV=development +PORT=3001 +APP_NAME=STATION BACKEND + +# ─── JWT ──────────────────────────────────────────────────────────────────────── +# Required. Minimum 32 characters. Use a strong random value in production. +JWT_SECRET=your-super-secret-jwt-key-minimum-32-chars +JWT_REFRESH_SECRET=your-super-secret-refresh-key-minimum-32-chars + +# ─── Database ─────────────────────────────────────────────────────────────────── DATABASE_HOST=localhost DATABASE_PORT=5433 DATABASE_USER=stationDbUser DATABASE_PASSWORD=stationDbPassword1 DATABASE_NAME=stationDb -# JWT Configuration -JWT_SECRET=your-super-secret-jwt-key-change-this-in-production - -# Redis Configuration +# ─── Redis ────────────────────────────────────────────────────────────────────── REDIS_HOST=localhost REDIS_PORT=6379 USE_REDIS_CACHE=true -# Application Configuration -PORT=3001 -APP_NAME=STATION BACKEND +# ─── CORS / Frontend ──────────────────────────────────────────────────────────── +# Origin allowed by CORS (frontend URL). Must be a valid URI. +ALLOWED_ORIGIN=http://localhost:5173 +# Base URL used to construct password reset links sent via email. +FRONTEND_URL=http://localhost:5173 -# UEX Sync Configuration +# ─── UEX Sync ─────────────────────────────────────────────────────────────────── UEX_SYNC_ENABLED=true UEX_CATEGORIES_SYNC_ENABLED=true UEX_ITEMS_SYNC_ENABLED=true diff --git a/backend/.env.test b/backend/.env.test index c27afb3..80ca9b8 100644 --- a/backend/.env.test +++ b/backend/.env.test @@ -3,7 +3,8 @@ DATABASE_PORT=5433 DATABASE_USER=stationDbUser DATABASE_PASSWORD=stationDbPassword1 DATABASE_NAME=stationDb -JWT_SECRET=test-jwt-secret-for-e2e-tests-only +JWT_SECRET=test-jwt-secret-for-e2e-tests-only-32chars +JWT_REFRESH_SECRET=test-jwt-refresh-secret-for-e2e-tests-32chars PORT=3000 APP_NAME=STATION BACKEND TEST USE_REDIS_CACHE=false diff --git a/backend/package.json b/backend/package.json index 2beee56..87600ce 100644 --- a/backend/package.json +++ b/backend/package.json @@ -53,6 +53,7 @@ "cookie-parser": "^1.4.7", "dotenv": "^16.4.5", "figlet": "^1.8.0", + "joi": "^18.1.2", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 80e507e..606e040 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -12,6 +12,7 @@ import { UserOrganizationRolesModule } from './modules/user-organization-roles/u import { PermissionsModule } from './modules/permissions/permissions.module'; import { AppService } from './app.service'; import { AppController } from './app.controller'; +import { envValidationSchema } from './config/env.validation'; import { DatabaseSeederModule } from './database/seeds/database-seeder.module'; import { AuditLogsModule } from './modules/audit-logs/audit-logs.module'; import { GamesModule } from './modules/games/games.module'; @@ -35,6 +36,8 @@ if (!isTest) { imports: [ ConfigModule.forRoot({ isGlobal: true, + validationSchema: envValidationSchema, + validationOptions: { abortEarly: false }, }), ...conditionalImports, CacheModule.registerAsync({ diff --git a/backend/src/config/env.validation.ts b/backend/src/config/env.validation.ts new file mode 100644 index 0000000..63f1b54 --- /dev/null +++ b/backend/src/config/env.validation.ts @@ -0,0 +1,49 @@ +import * as Joi from 'joi'; + +export const envValidationSchema = Joi.object({ + // Application + NODE_ENV: Joi.string() + .valid('development', 'production', 'test') + .default('development'), + PORT: Joi.number().default(3001), + APP_NAME: Joi.string().default('STATION BACKEND'), + + // JWT + JWT_SECRET: Joi.string().min(32).required(), + JWT_REFRESH_SECRET: Joi.string().min(32).required(), + + // Database + DATABASE_HOST: Joi.string().required(), + DATABASE_PORT: Joi.number().default(5433), + DATABASE_USER: Joi.string().required(), + DATABASE_PASSWORD: Joi.string().required(), + DATABASE_NAME: Joi.string().required(), + + // Redis + REDIS_HOST: Joi.string().default('localhost'), + REDIS_PORT: Joi.number().default(6379), + USE_REDIS_CACHE: Joi.string().valid('true', 'false').default('true'), + + // CORS / Frontend + ALLOWED_ORIGIN: Joi.string().uri().default('http://localhost:5173'), + FRONTEND_URL: Joi.string().uri().default('http://localhost:5173'), + + // UEX Sync (all optional — service degrades gracefully when disabled) + UEX_SYNC_ENABLED: Joi.string().valid('true', 'false').default('false'), + UEX_CATEGORIES_SYNC_ENABLED: Joi.string() + .valid('true', 'false') + .default('false'), + UEX_ITEMS_SYNC_ENABLED: Joi.string().valid('true', 'false').default('false'), + UEX_LOCATIONS_SYNC_ENABLED: Joi.string() + .valid('true', 'false') + .default('false'), + UEX_API_BASE_URL: Joi.string().uri().default('https://uexcorp.space/api/2.0'), + UEX_TIMEOUT_MS: Joi.number().default(60000), + UEX_BATCH_SIZE: Joi.number().default(100), + UEX_CONCURRENT_CATEGORIES: Joi.number().default(3), + UEX_RETRY_ATTEMPTS: Joi.number().default(3), + UEX_BACKOFF_BASE_MS: Joi.number().default(1000), + UEX_RATE_LIMIT_PAUSE_MS: Joi.number().default(2000), + UEX_ENDPOINTS_PAUSE_MS: Joi.number().default(2000), + UEX_API_KEY: Joi.string().allow('').default(''), +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 662e1f9..7310b1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,6 +86,9 @@ importers: figlet: specifier: ^1.8.0 version: 1.9.4 + joi: + specifier: ^18.1.2 + version: 18.1.2 passport: specifier: ^0.7.0 version: 0.7.0 @@ -713,6 +716,26 @@ packages: resolution: {integrity: sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@hapi/address@5.1.1': + resolution: {integrity: sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==} + engines: {node: '>=14.0.0'} + + '@hapi/formula@3.0.2': + resolution: {integrity: sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==} + + '@hapi/hoek@11.0.7': + resolution: {integrity: sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==} + + '@hapi/pinpoint@2.0.1': + resolution: {integrity: sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==} + + '@hapi/tlds@1.1.6': + resolution: {integrity: sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw==} + engines: {node: '>=14.0.0'} + + '@hapi/topo@6.0.2': + resolution: {integrity: sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==} + '@humanwhocodes/config-array@0.13.0': resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} engines: {node: '>=10.10.0'} @@ -1297,6 +1320,9 @@ packages: '@sqltools/formatter@1.2.5': resolution: {integrity: sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@testing-library/dom@9.3.4': resolution: {integrity: sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==} engines: {node: '>=14'} @@ -2598,11 +2624,12 @@ packages: glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me globals@13.24.0: resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} @@ -3020,6 +3047,10 @@ packages: node-notifier: optional: true + joi@18.1.2: + resolution: {integrity: sha512-rF5MAmps5esSlhCA+N1b6IYHDw9j/btzGaqfgie522jS02Ju/HXBxamlXVlKEHAxoMKQL77HWI8jlqWsFuekZA==} + engines: {node: '>= 20'} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -4954,6 +4985,22 @@ snapshots: '@eslint/js@9.39.1': {} + '@hapi/address@5.1.1': + dependencies: + '@hapi/hoek': 11.0.7 + + '@hapi/formula@3.0.2': {} + + '@hapi/hoek@11.0.7': {} + + '@hapi/pinpoint@2.0.1': {} + + '@hapi/tlds@1.1.6': {} + + '@hapi/topo@6.0.2': + dependencies: + '@hapi/hoek': 11.0.7 + '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 @@ -5601,6 +5648,8 @@ snapshots: '@sqltools/formatter@1.2.5': {} + '@standard-schema/spec@1.1.0': {} + '@testing-library/dom@9.3.4': dependencies: '@babel/code-frame': 7.27.1 @@ -7764,6 +7813,16 @@ snapshots: - supports-color - ts-node + joi@18.1.2: + dependencies: + '@hapi/address': 5.1.1 + '@hapi/formula': 3.0.2 + '@hapi/hoek': 11.0.7 + '@hapi/pinpoint': 2.0.1 + '@hapi/tlds': 1.1.6 + '@hapi/topo': 6.0.2 + '@standard-schema/spec': 1.1.0 + js-tokens@4.0.0: {} js-yaml@3.14.2: From 008fedc50265efd46c7d76c189526b52a41888ac Mon Sep 17 00:00:00 2001 From: Demian Date: Mon, 13 Apr 2026 00:22:51 -0400 Subject: [PATCH 3/9] fix: update CI env vars and migrate E2E tests to cookie auth - Extend JWT_SECRET to 32+ chars in all CI jobs to satisfy Joi min(32) - Add JWT_REFRESH_SECRET to all CI jobs (now required by env validation) - Add USE_REDIS_CACHE=false to unit and E2E test jobs - Replace Bearer token auth with httpOnly cookie auth in all E2E test files (roles, organizations, user-organization-roles, auth-password-reset) - Add cookie-parser middleware to all E2E test app instances --- .github/workflows/backend-ci.yml | 14 ++++-- backend/test/auth-password-reset.e2e-spec.ts | 25 +++++++---- backend/test/organizations.e2e-spec.ts | 31 +++++++------ backend/test/roles.e2e-spec.ts | 27 +++++++----- .../test/user-organization-roles.e2e-spec.ts | 43 +++++++++++-------- 5 files changed, 87 insertions(+), 53 deletions(-) diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 544755a..1c5ce5c 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -41,7 +41,8 @@ jobs: DATABASE_USER: test DATABASE_PASSWORD: test DATABASE_NAME: station_test - JWT_SECRET: test-secret-key-for-ci + JWT_SECRET: test-secret-key-for-ci-minimum-32-chars + JWT_REFRESH_SECRET: test-refresh-secret-for-ci-min-32-chars run: pnpm run lint unit-tests: @@ -75,7 +76,9 @@ jobs: DATABASE_USER: test DATABASE_PASSWORD: test DATABASE_NAME: station_test - JWT_SECRET: test-secret-key-for-ci + JWT_SECRET: test-secret-key-for-ci-minimum-32-chars + JWT_REFRESH_SECRET: test-refresh-secret-for-ci-min-32-chars + USE_REDIS_CACHE: 'false' run: pnpm test --coverage --passWithNoTests - name: Upload coverage reports @@ -131,7 +134,9 @@ jobs: DATABASE_USER: test DATABASE_PASSWORD: test DATABASE_NAME: station_test - JWT_SECRET: test-secret-key-for-ci + JWT_SECRET: test-secret-key-for-ci-minimum-32-chars + JWT_REFRESH_SECRET: test-refresh-secret-for-ci-min-32-chars + USE_REDIS_CACHE: 'false' run: pnpm run test:e2e build: @@ -165,7 +170,8 @@ jobs: DATABASE_USER: test DATABASE_PASSWORD: test DATABASE_NAME: station_test - JWT_SECRET: test-secret-key-for-ci + JWT_SECRET: test-secret-key-for-ci-minimum-32-chars + JWT_REFRESH_SECRET: test-refresh-secret-for-ci-min-32-chars run: pnpm run build - name: Upload build artifacts diff --git a/backend/test/auth-password-reset.e2e-spec.ts b/backend/test/auth-password-reset.e2e-spec.ts index 77b5def..a558dc3 100644 --- a/backend/test/auth-password-reset.e2e-spec.ts +++ b/backend/test/auth-password-reset.e2e-spec.ts @@ -1,6 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication, ValidationPipe } from '@nestjs/common'; import request from 'supertest'; +import cookieParser from 'cookie-parser'; import { AppModule } from '../src/app.module'; import { DataSource } from 'typeorm'; import { User } from '../src/modules/users/user.entity'; @@ -12,7 +13,7 @@ describe('Auth - Password Reset (e2e)', () => { let app: INestApplication; let dataSource: DataSource; let testUser: User; - let accessToken: string; + let authCookie: string; beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ @@ -20,6 +21,7 @@ describe('Auth - Password Reset (e2e)', () => { }).compile(); app = moduleFixture.createNestApplication(); + app.use(cookieParser()); app.useGlobalPipes( new ValidationPipe({ whitelist: true, @@ -44,7 +46,7 @@ describe('Auth - Password Reset (e2e)', () => { isActive: true, }); - // Login to get access token + // Login to get access token cookie const loginResponse = await request(app.getHttpServer()) .post('/auth/login') .send({ @@ -53,7 +55,12 @@ describe('Auth - Password Reset (e2e)', () => { }) .expect(201); - accessToken = loginResponse.body.access_token; + const setCookies = loginResponse.headers[ + 'set-cookie' + ] as unknown as string[]; + authCookie = + setCookies.find((c) => c.startsWith('access_token='))?.split(';')[0] ?? + ''; }); afterAll(async () => { @@ -252,7 +259,7 @@ describe('Auth - Password Reset (e2e)', () => { const response = await request(app.getHttpServer()) .post('/auth/change-password') - .set('Authorization', `Bearer ${accessToken}`) + .set('Cookie', authCookie) .send({ currentPassword, newPassword, @@ -279,7 +286,7 @@ describe('Auth - Password Reset (e2e)', () => { it('should reject incorrect current password', async () => { await request(app.getHttpServer()) .post('/auth/change-password') - .set('Authorization', `Bearer ${accessToken}`) + .set('Cookie', authCookie) .send({ currentPassword: 'wrongPassword', newPassword: 'newPassword789', @@ -300,7 +307,7 @@ describe('Auth - Password Reset (e2e)', () => { it('should reject invalid access token', async () => { await request(app.getHttpServer()) .post('/auth/change-password') - .set('Authorization', 'Bearer invalid-token') + .set('Cookie', 'access_token=invalid-token') .send({ currentPassword: 'password123', newPassword: 'newPassword789', @@ -311,7 +318,7 @@ describe('Auth - Password Reset (e2e)', () => { it('should reject missing currentPassword', async () => { await request(app.getHttpServer()) .post('/auth/change-password') - .set('Authorization', `Bearer ${accessToken}`) + .set('Cookie', authCookie) .send({ newPassword: 'newPassword789', }) @@ -321,7 +328,7 @@ describe('Auth - Password Reset (e2e)', () => { it('should reject missing newPassword', async () => { await request(app.getHttpServer()) .post('/auth/change-password') - .set('Authorization', `Bearer ${accessToken}`) + .set('Cookie', authCookie) .send({ currentPassword: 'password123', }) @@ -331,7 +338,7 @@ describe('Auth - Password Reset (e2e)', () => { it('should reject newPassword shorter than 6 characters', async () => { await request(app.getHttpServer()) .post('/auth/change-password') - .set('Authorization', `Bearer ${accessToken}`) + .set('Cookie', authCookie) .send({ currentPassword: 'password123', newPassword: '12345', diff --git a/backend/test/organizations.e2e-spec.ts b/backend/test/organizations.e2e-spec.ts index 415d6d6..f606b59 100644 --- a/backend/test/organizations.e2e-spec.ts +++ b/backend/test/organizations.e2e-spec.ts @@ -1,6 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication, ValidationPipe } from '@nestjs/common'; import request from 'supertest'; +import cookieParser from 'cookie-parser'; import { AppModule } from '../src/app.module'; import { DatabaseSeederService } from '../src/database/seeds/database-seeder.service'; import { DataSource } from 'typeorm'; @@ -8,7 +9,7 @@ import { seedSystemUser } from './helpers/seed-system-user'; describe('Organizations (e2e)', () => { let app: INestApplication; - let authToken: string; + let authCookie: string; let createdOrgId: number; beforeAll(async () => { @@ -17,6 +18,7 @@ describe('Organizations (e2e)', () => { }).compile(); app = moduleFixture.createNestApplication(); + app.use(cookieParser()); app.useGlobalPipes(new ValidationPipe()); await app.init(); @@ -44,7 +46,12 @@ describe('Organizations (e2e)', () => { password: 'password123', }); - authToken = loginResponse.body.access_token; + const setCookies = loginResponse.headers[ + 'set-cookie' + ] as unknown as string[]; + authCookie = + setCookies.find((c) => c.startsWith('access_token='))?.split(';')[0] ?? + ''; }); afterAll(async () => { @@ -55,7 +62,7 @@ describe('Organizations (e2e)', () => { it('should create a new organization', () => { return request(app.getHttpServer()) .post('/organizations') - .set('Authorization', `Bearer ${authToken}`) + .set('Cookie', authCookie) .send({ name: 'Test Corp', description: 'A test organization', @@ -83,7 +90,7 @@ describe('Organizations (e2e)', () => { it('should create organization with minimal data', () => { return request(app.getHttpServer()) .post('/organizations') - .set('Authorization', `Bearer ${authToken}`) + .set('Cookie', authCookie) .send({ name: 'Minimal Org', }) @@ -99,7 +106,7 @@ describe('Organizations (e2e)', () => { it('should return all active organizations', () => { return request(app.getHttpServer()) .get('/organizations') - .set('Authorization', `Bearer ${authToken}`) + .set('Cookie', authCookie) .expect(200) .then((response) => { expect(Array.isArray(response.body)).toBe(true); @@ -112,7 +119,7 @@ describe('Organizations (e2e)', () => { it('should return a specific organization', () => { return request(app.getHttpServer()) .get(`/organizations/${createdOrgId}`) - .set('Authorization', `Bearer ${authToken}`) + .set('Cookie', authCookie) .expect(200) .then((response) => { expect(response.body.id).toBe(createdOrgId); @@ -123,7 +130,7 @@ describe('Organizations (e2e)', () => { it('should return 404 for non-existent organization', () => { return request(app.getHttpServer()) .get('/organizations/99999') - .set('Authorization', `Bearer ${authToken}`) + .set('Cookie', authCookie) .expect(404); }); }); @@ -132,7 +139,7 @@ describe('Organizations (e2e)', () => { it('should return organization with members', () => { return request(app.getHttpServer()) .get(`/organizations/${createdOrgId}/members`) - .set('Authorization', `Bearer ${authToken}`) + .set('Cookie', authCookie) .expect(200) .then((response) => { expect(response.body).toHaveProperty('id'); @@ -146,7 +153,7 @@ describe('Organizations (e2e)', () => { it('should update an organization', () => { return request(app.getHttpServer()) .put(`/organizations/${createdOrgId}`) - .set('Authorization', `Bearer ${authToken}`) + .set('Cookie', authCookie) .send({ description: 'Updated organization description', isActive: true, @@ -162,7 +169,7 @@ describe('Organizations (e2e)', () => { it('should deactivate an organization', () => { return request(app.getHttpServer()) .put(`/organizations/${createdOrgId}`) - .set('Authorization', `Bearer ${authToken}`) + .set('Cookie', authCookie) .send({ isActive: false, }) @@ -177,14 +184,14 @@ describe('Organizations (e2e)', () => { it('should delete an organization', () => { return request(app.getHttpServer()) .delete(`/organizations/${createdOrgId}`) - .set('Authorization', `Bearer ${authToken}`) + .set('Cookie', authCookie) .expect(204); }); it('should return 404 when deleting non-existent organization', () => { return request(app.getHttpServer()) .delete('/organizations/99999') - .set('Authorization', `Bearer ${authToken}`) + .set('Cookie', authCookie) .expect(404); }); }); diff --git a/backend/test/roles.e2e-spec.ts b/backend/test/roles.e2e-spec.ts index 6eecf38..5abd305 100644 --- a/backend/test/roles.e2e-spec.ts +++ b/backend/test/roles.e2e-spec.ts @@ -1,13 +1,14 @@ import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication, ValidationPipe } from '@nestjs/common'; import request from 'supertest'; +import cookieParser from 'cookie-parser'; import { AppModule } from '../src/app.module'; import { DataSource } from 'typeorm'; import { seedSystemUser } from './helpers/seed-system-user'; describe('Roles (e2e)', () => { let app: INestApplication; - let authToken: string; + let authCookie: string; let createdRoleId: number; beforeAll(async () => { @@ -16,6 +17,7 @@ describe('Roles (e2e)', () => { }).compile(); app = moduleFixture.createNestApplication(); + app.use(cookieParser()); app.useGlobalPipes(new ValidationPipe()); await app.init(); @@ -37,7 +39,12 @@ describe('Roles (e2e)', () => { password: 'password123', }); - authToken = loginResponse.body.access_token; + const setCookies = loginResponse.headers[ + 'set-cookie' + ] as unknown as string[]; + authCookie = + setCookies.find((c) => c.startsWith('access_token='))?.split(';')[0] ?? + ''; }); afterAll(async () => { @@ -48,7 +55,7 @@ describe('Roles (e2e)', () => { it('should create a new role', () => { return request(app.getHttpServer()) .post('/roles') - .set('Authorization', `Bearer ${authToken}`) + .set('Cookie', authCookie) .send({ name: 'Test Admin', description: 'Test administrator role', @@ -73,7 +80,7 @@ describe('Roles (e2e)', () => { it('should fail to create role with duplicate name', () => { return request(app.getHttpServer()) .post('/roles') - .set('Authorization', `Bearer ${authToken}`) + .set('Cookie', authCookie) .send({ name: 'Test Admin', description: 'Duplicate role', @@ -95,7 +102,7 @@ describe('Roles (e2e)', () => { it('should return all roles', () => { return request(app.getHttpServer()) .get('/roles') - .set('Authorization', `Bearer ${authToken}`) + .set('Cookie', authCookie) .expect(200) .then((response) => { expect(Array.isArray(response.body)).toBe(true); @@ -108,7 +115,7 @@ describe('Roles (e2e)', () => { it('should return a specific role', () => { return request(app.getHttpServer()) .get(`/roles/${createdRoleId}`) - .set('Authorization', `Bearer ${authToken}`) + .set('Cookie', authCookie) .expect(200) .then((response) => { expect(response.body.id).toBe(createdRoleId); @@ -119,7 +126,7 @@ describe('Roles (e2e)', () => { it('should return 404 for non-existent role', () => { return request(app.getHttpServer()) .get('/roles/99999') - .set('Authorization', `Bearer ${authToken}`) + .set('Cookie', authCookie) .expect(404); }); }); @@ -128,7 +135,7 @@ describe('Roles (e2e)', () => { it('should update a role', () => { return request(app.getHttpServer()) .put(`/roles/${createdRoleId}`) - .set('Authorization', `Bearer ${authToken}`) + .set('Cookie', authCookie) .send({ description: 'Updated description', permissions: { @@ -149,14 +156,14 @@ describe('Roles (e2e)', () => { it('should delete a role', () => { return request(app.getHttpServer()) .delete(`/roles/${createdRoleId}`) - .set('Authorization', `Bearer ${authToken}`) + .set('Cookie', authCookie) .expect(204); }); it('should return 404 when deleting non-existent role', () => { return request(app.getHttpServer()) .delete('/roles/99999') - .set('Authorization', `Bearer ${authToken}`) + .set('Cookie', authCookie) .expect(404); }); }); diff --git a/backend/test/user-organization-roles.e2e-spec.ts b/backend/test/user-organization-roles.e2e-spec.ts index 1fa76f1..6c528de 100644 --- a/backend/test/user-organization-roles.e2e-spec.ts +++ b/backend/test/user-organization-roles.e2e-spec.ts @@ -1,6 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication, ValidationPipe } from '@nestjs/common'; import request from 'supertest'; +import cookieParser from 'cookie-parser'; import { AppModule } from '../src/app.module'; import { DatabaseSeederService } from '../src/database/seeds/database-seeder.service'; import { DataSource } from 'typeorm'; @@ -8,7 +9,7 @@ import { seedSystemUser } from './helpers/seed-system-user'; describe('UserOrganizationRoles (e2e)', () => { let app: INestApplication; - let authToken: string; + let authCookie: string; let userId: number; let organizationId: number; let roleId: number; @@ -19,6 +20,7 @@ describe('UserOrganizationRoles (e2e)', () => { }).compile(); app = moduleFixture.createNestApplication(); + app.use(cookieParser()); app.useGlobalPipes(new ValidationPipe()); await app.init(); @@ -46,13 +48,18 @@ describe('UserOrganizationRoles (e2e)', () => { password: 'password123', }); - authToken = loginResponse.body.access_token; + const setCookies = loginResponse.headers[ + 'set-cookie' + ] as unknown as string[]; + authCookie = + setCookies.find((c) => c.startsWith('access_token='))?.split(';')[0] ?? + ''; userId = loginResponse.body.userId || 1; // Create a test organization const orgResponse = await request(app.getHttpServer()) .post('/organizations') - .set('Authorization', `Bearer ${authToken}`) + .set('Cookie', authCookie) .send({ name: 'Test Organization', description: 'For role testing', @@ -63,7 +70,7 @@ describe('UserOrganizationRoles (e2e)', () => { // Create a test role const roleResponse = await request(app.getHttpServer()) .post('/roles') - .set('Authorization', `Bearer ${authToken}`) + .set('Cookie', authCookie) .send({ name: 'Test Role', permissions: { @@ -83,7 +90,7 @@ describe('UserOrganizationRoles (e2e)', () => { it('should assign a role to a user in an organization', () => { return request(app.getHttpServer()) .post('/user-organization-roles/assign') - .set('Authorization', `Bearer ${authToken}`) + .set('Cookie', authCookie) .send({ userId, organizationId, @@ -101,7 +108,7 @@ describe('UserOrganizationRoles (e2e)', () => { it('should fail to assign duplicate role', () => { return request(app.getHttpServer()) .post('/user-organization-roles/assign') - .set('Authorization', `Bearer ${authToken}`) + .set('Cookie', authCookie) .send({ userId, organizationId, @@ -113,7 +120,7 @@ describe('UserOrganizationRoles (e2e)', () => { it('should fail with invalid user', () => { return request(app.getHttpServer()) .post('/user-organization-roles/assign') - .set('Authorization', `Bearer ${authToken}`) + .set('Cookie', authCookie) .send({ userId: 99999, organizationId, @@ -128,7 +135,7 @@ describe('UserOrganizationRoles (e2e)', () => { // Create additional roles const role2Response = await request(app.getHttpServer()) .post('/roles') - .set('Authorization', `Bearer ${authToken}`) + .set('Cookie', authCookie) .send({ name: 'Developer Role', permissions: { canDeploy: true }, @@ -136,7 +143,7 @@ describe('UserOrganizationRoles (e2e)', () => { const role3Response = await request(app.getHttpServer()) .post('/roles') - .set('Authorization', `Bearer ${authToken}`) + .set('Cookie', authCookie) .send({ name: 'Viewer Role', permissions: { canView: true }, @@ -144,7 +151,7 @@ describe('UserOrganizationRoles (e2e)', () => { return request(app.getHttpServer()) .post('/user-organization-roles/assign-multiple') - .set('Authorization', `Bearer ${authToken}`) + .set('Cookie', authCookie) .send({ userId, organizationId, @@ -164,7 +171,7 @@ describe('UserOrganizationRoles (e2e)', () => { .get( `/user-organization-roles/user/${userId}/organization/${organizationId}`, ) - .set('Authorization', `Bearer ${authToken}`) + .set('Cookie', authCookie) .expect(200) .then((response) => { expect(Array.isArray(response.body)).toBe(true); @@ -178,7 +185,7 @@ describe('UserOrganizationRoles (e2e)', () => { it('should get all organizations for a user', () => { return request(app.getHttpServer()) .get(`/user-organization-roles/user/${userId}/organizations`) - .set('Authorization', `Bearer ${authToken}`) + .set('Cookie', authCookie) .expect(200) .then((response) => { expect(Array.isArray(response.body)).toBe(true); @@ -193,7 +200,7 @@ describe('UserOrganizationRoles (e2e)', () => { it('should get all members of an organization', () => { return request(app.getHttpServer()) .get(`/user-organization-roles/organization/${organizationId}/members`) - .set('Authorization', `Bearer ${authToken}`) + .set('Cookie', authCookie) .expect(200) .then((response) => { expect(Array.isArray(response.body)).toBe(true); @@ -210,7 +217,7 @@ describe('UserOrganizationRoles (e2e)', () => { .get( `/user-organization-roles/organization/${organizationId}/role/${roleId}/users`, ) - .set('Authorization', `Bearer ${authToken}`) + .set('Cookie', authCookie) .expect(200) .then((response) => { expect(Array.isArray(response.body)).toBe(true); @@ -226,7 +233,7 @@ describe('UserOrganizationRoles (e2e)', () => { .delete( `/user-organization-roles/user/${userId}/organization/${organizationId}/role/${roleId}`, ) - .set('Authorization', `Bearer ${authToken}`) + .set('Cookie', authCookie) .expect(204); }); @@ -235,7 +242,7 @@ describe('UserOrganizationRoles (e2e)', () => { .delete( `/user-organization-roles/user/${userId}/organization/${organizationId}/role/99999`, ) - .set('Authorization', `Bearer ${authToken}`) + .set('Cookie', authCookie) .expect(404); }); }); @@ -245,7 +252,7 @@ describe('UserOrganizationRoles (e2e)', () => { // First assign a role await request(app.getHttpServer()) .post('/user-organization-roles/assign') - .set('Authorization', `Bearer ${authToken}`) + .set('Cookie', authCookie) .send({ userId, organizationId, @@ -254,7 +261,7 @@ describe('UserOrganizationRoles (e2e)', () => { return request(app.getHttpServer()) .get(`/permissions/user/${userId}/organization/${organizationId}`) - .set('Authorization', `Bearer ${authToken}`) + .set('Cookie', authCookie) .expect(200) .then((response) => { expect(response.body).toHaveProperty('permissions'); From 54443399d5ae9b71083ad69fab42e73174bb6bf2 Mon Sep 17 00:00:00 2001 From: Demian Date: Mon, 13 Apr 2026 00:35:29 -0400 Subject: [PATCH 4/9] fix: address review comments on env validation and auth cookie handling - Use Joi.boolean() for UEX_*_SYNC_ENABLED vars so env strings like 'false' are coerced to actual booleans; configService.get() in the UEX sync scheduler would treat string 'false' as truthy - Refactor auth cookie options into a private cookieOptions(maxAge) helper to eliminate duplication across login/refresh/logout; change sameSite from 'lax' to 'strict' to minimize CSRF surface - Derive clearCookie options from cookieOptions() to keep security flags in sync; clear with same httpOnly/secure/sameSite as when setting - Return { message, username } from login so clients can identify the authenticated user without a separate round-trip - Add Authorization: Bearer header as fallback extractor in JwtStrategy (after cookie) so Swagger UI and non-browser clients continue to work - Align @ApiBearerAuth decorator with Swagger scheme name 'access-token' - Capture userId from /auth/register response instead of login body since login no longer returns userId in the response --- backend/src/config/env.validation.ts | 14 ++-- backend/src/modules/auth/auth.controller.ts | 72 +++++++++---------- backend/src/modules/auth/jwt.strategy.ts | 1 + .../test/user-organization-roles.e2e-spec.ts | 15 ++-- 4 files changed, 52 insertions(+), 50 deletions(-) diff --git a/backend/src/config/env.validation.ts b/backend/src/config/env.validation.ts index 63f1b54..5c9753f 100644 --- a/backend/src/config/env.validation.ts +++ b/backend/src/config/env.validation.ts @@ -29,14 +29,12 @@ export const envValidationSchema = Joi.object({ FRONTEND_URL: Joi.string().uri().default('http://localhost:5173'), // UEX Sync (all optional — service degrades gracefully when disabled) - UEX_SYNC_ENABLED: Joi.string().valid('true', 'false').default('false'), - UEX_CATEGORIES_SYNC_ENABLED: Joi.string() - .valid('true', 'false') - .default('false'), - UEX_ITEMS_SYNC_ENABLED: Joi.string().valid('true', 'false').default('false'), - UEX_LOCATIONS_SYNC_ENABLED: Joi.string() - .valid('true', 'false') - .default('false'), + // Use Joi.boolean() so env strings like 'false' are coerced to actual + // booleans; configService.get(...) in the scheduler relies on this. + UEX_SYNC_ENABLED: Joi.boolean().default(false), + UEX_CATEGORIES_SYNC_ENABLED: Joi.boolean().default(false), + UEX_ITEMS_SYNC_ENABLED: Joi.boolean().default(false), + UEX_LOCATIONS_SYNC_ENABLED: Joi.boolean().default(false), UEX_API_BASE_URL: Joi.string().uri().default('https://uexcorp.space/api/2.0'), UEX_TIMEOUT_MS: Joi.number().default(60000), UEX_BATCH_SIZE: Joi.number().default(100), diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index 4e5db24..1fd220f 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -30,6 +30,16 @@ import { export class AuthController { constructor(private authService: AuthService) {} + private cookieOptions(maxAge: number) { + return { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict' as const, + path: '/', + maxAge, + }; + } + @ApiOperation({ summary: 'Login user' }) @ApiBody({ schema: { @@ -48,25 +58,19 @@ export class AuthController { @Request() req: ExpressRequest, @Res({ passthrough: true }) res: Response, ) { - const tokens = await this.authService.login( - req.user as Parameters[0], + const user = req.user as Parameters[0]; + const tokens = await this.authService.login(user); + res.cookie( + 'access_token', + tokens.accessToken, + this.cookieOptions(15 * 60 * 1000), ); - const isProduction = process.env.NODE_ENV === 'production'; - const cookieBase = { - httpOnly: true, - secure: isProduction, - sameSite: 'lax' as const, - path: '/', - }; - res.cookie('access_token', tokens.accessToken, { - ...cookieBase, - maxAge: 15 * 60 * 1000, - }); - res.cookie('refresh_token', tokens.refreshToken, { - ...cookieBase, - maxAge: 7 * 24 * 60 * 60 * 1000, - }); - return { message: 'Login successful' }; + res.cookie( + 'refresh_token', + tokens.refreshToken, + this.cookieOptions(7 * 24 * 60 * 60 * 1000), + ); + return { message: 'Login successful', username: user.username }; } @ApiOperation({ summary: 'Register new user' }) @@ -90,21 +94,16 @@ export class AuthController { const tokens = await this.authService.refreshAccessToken( req.user.refreshToken, ); - const isProduction = process.env.NODE_ENV === 'production'; - const cookieBase = { - httpOnly: true, - secure: isProduction, - sameSite: 'lax' as const, - path: '/', - }; - res.cookie('access_token', tokens.accessToken, { - ...cookieBase, - maxAge: 15 * 60 * 1000, - }); - res.cookie('refresh_token', tokens.refreshToken, { - ...cookieBase, - maxAge: 7 * 24 * 60 * 60 * 1000, - }); + res.cookie( + 'access_token', + tokens.accessToken, + this.cookieOptions(15 * 60 * 1000), + ); + res.cookie( + 'refresh_token', + tokens.refreshToken, + this.cookieOptions(7 * 24 * 60 * 60 * 1000), + ); return { message: 'Tokens refreshed successfully' }; } @@ -115,8 +114,9 @@ export class AuthController { @Post('logout') async logout(@Request() req: any, @Res({ passthrough: true }) res: Response) { await this.authService.revokeRefreshToken(req.user.refreshToken); - res.clearCookie('access_token', { path: '/' }); - res.clearCookie('refresh_token', { path: '/' }); + const { maxAge: _maxAge, ...clearOpts } = this.cookieOptions(0); + res.clearCookie('access_token', clearOpts); + res.clearCookie('refresh_token', clearOpts); return { message: 'Logged out successfully' }; } @@ -143,7 +143,7 @@ export class AuthController { } @ApiOperation({ summary: 'Change password (requires authentication)' }) - @ApiBearerAuth() + @ApiBearerAuth('access-token') @ApiBody({ type: ChangePasswordDto }) @ApiResponse({ status: 200, description: 'Password changed successfully' }) @ApiResponse({ status: 400, description: 'Current password is incorrect' }) diff --git a/backend/src/modules/auth/jwt.strategy.ts b/backend/src/modules/auth/jwt.strategy.ts index 732be7e..c3f4cea 100644 --- a/backend/src/modules/auth/jwt.strategy.ts +++ b/backend/src/modules/auth/jwt.strategy.ts @@ -10,6 +10,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { super({ jwtFromRequest: ExtractJwt.fromExtractors([ (req: Request) => req?.cookies?.access_token ?? null, + ExtractJwt.fromAuthHeaderAsBearerToken(), ]), ignoreExpiration: false, secretOrKey: configService.get('JWT_SECRET'), diff --git a/backend/test/user-organization-roles.e2e-spec.ts b/backend/test/user-organization-roles.e2e-spec.ts index 6c528de..980cf92 100644 --- a/backend/test/user-organization-roles.e2e-spec.ts +++ b/backend/test/user-organization-roles.e2e-spec.ts @@ -35,11 +35,15 @@ describe('UserOrganizationRoles (e2e)', () => { await seeder.seedAll(); // Register and login a test user - await request(app.getHttpServer()).post('/auth/register').send({ - username: 'roleuser', - email: 'roleuser@example.com', - password: 'password123', - }); + const registerResponse = await request(app.getHttpServer()) + .post('/auth/register') + .send({ + username: 'roleuser', + email: 'roleuser@example.com', + password: 'password123', + }); + + userId = registerResponse.body.id; const loginResponse = await request(app.getHttpServer()) .post('/auth/login') @@ -54,7 +58,6 @@ describe('UserOrganizationRoles (e2e)', () => { authCookie = setCookies.find((c) => c.startsWith('access_token='))?.split(';')[0] ?? ''; - userId = loginResponse.body.userId || 1; // Create a test organization const orgResponse = await request(app.getHttpServer()) From 89b0c51df5bb5fe00a83718b9934406f76b5e112 Mon Sep 17 00:00:00 2001 From: Demian Date: Mon, 13 Apr 2026 00:55:53 -0400 Subject: [PATCH 5/9] fix: read PORT as number to ensure valid TCP port for app.listen configService.get() combined with the Joi schema (Joi.number()) returns a proper number; the string fallback could cause Node to treat a numeric string as a named pipe path. --- backend/src/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/main.ts b/backend/src/main.ts index 7951b2c..0fab9c4 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -16,7 +16,7 @@ async function bootstrap() { // Application configuration const configService = app.get(ConfigService); - const port = configService.get('PORT') || 3001; + const port = configService.get('PORT') ?? 3001; const appName = configService.get('APP_NAME') || 'STATION BACKEND'; // ASCII Art for Application Name From 5d15684cd2432384f910267610ccfd422b9ae843 Mon Sep 17 00:00:00 2001 From: Demian Date: Mon, 13 Apr 2026 01:16:59 -0400 Subject: [PATCH 6/9] fix: address PR118 review comments - Remove unused JWT_REFRESH_SECRET from Joi schema and CI env vars; refresh tokens are opaque DB tokens, not JWTs signed with a secret - Downgrade joi from ^18.1.2 (requires Node >= 20) to ^17.13.3 which is compatible with the project's Node >= 18 requirement - Add @HttpCode(HttpStatus.OK) to all auth POST endpoints that document 200 (login, refresh, logout, forgot/reset/change-password) so Swagger docs match actual HTTP status codes - Update e2e tests to expect 200 on those endpoints --- .github/workflows/backend-ci.yml | 4 - backend/package.json | 2 +- backend/src/config/env.validation.ts | 1 - backend/src/modules/auth/auth.controller.ts | 8 ++ backend/test/auth-password-reset.e2e-spec.ts | 14 ++-- pnpm-lock.yaml | 83 ++++++++------------ 6 files changed, 50 insertions(+), 62 deletions(-) diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 1c5ce5c..11681df 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -42,7 +42,6 @@ jobs: DATABASE_PASSWORD: test DATABASE_NAME: station_test JWT_SECRET: test-secret-key-for-ci-minimum-32-chars - JWT_REFRESH_SECRET: test-refresh-secret-for-ci-min-32-chars run: pnpm run lint unit-tests: @@ -77,7 +76,6 @@ jobs: DATABASE_PASSWORD: test DATABASE_NAME: station_test JWT_SECRET: test-secret-key-for-ci-minimum-32-chars - JWT_REFRESH_SECRET: test-refresh-secret-for-ci-min-32-chars USE_REDIS_CACHE: 'false' run: pnpm test --coverage --passWithNoTests @@ -135,7 +133,6 @@ jobs: DATABASE_PASSWORD: test DATABASE_NAME: station_test JWT_SECRET: test-secret-key-for-ci-minimum-32-chars - JWT_REFRESH_SECRET: test-refresh-secret-for-ci-min-32-chars USE_REDIS_CACHE: 'false' run: pnpm run test:e2e @@ -171,7 +168,6 @@ jobs: DATABASE_PASSWORD: test DATABASE_NAME: station_test JWT_SECRET: test-secret-key-for-ci-minimum-32-chars - JWT_REFRESH_SECRET: test-refresh-secret-for-ci-min-32-chars run: pnpm run build - name: Upload build artifacts diff --git a/backend/package.json b/backend/package.json index 87600ce..e38184e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -53,7 +53,7 @@ "cookie-parser": "^1.4.7", "dotenv": "^16.4.5", "figlet": "^1.8.0", - "joi": "^18.1.2", + "joi": "^17.13.3", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", diff --git a/backend/src/config/env.validation.ts b/backend/src/config/env.validation.ts index 5c9753f..3134eab 100644 --- a/backend/src/config/env.validation.ts +++ b/backend/src/config/env.validation.ts @@ -10,7 +10,6 @@ export const envValidationSchema = Joi.object({ // JWT JWT_SECRET: Joi.string().min(32).required(), - JWT_REFRESH_SECRET: Joi.string().min(32).required(), // Database DATABASE_HOST: Joi.string().required(), diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index 1fd220f..c07cb4e 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -5,6 +5,8 @@ import { Request, Body, Res, + HttpCode, + HttpStatus, } from '@nestjs/common'; import { ApiTags, @@ -53,6 +55,7 @@ export class AuthController { @ApiResponse({ status: 200, description: 'Successfully logged in' }) @ApiResponse({ status: 401, description: 'Invalid credentials' }) @UseGuards(LocalAuthGuard) + @HttpCode(HttpStatus.OK) @Post('login') async login( @Request() req: ExpressRequest, @@ -86,6 +89,7 @@ export class AuthController { @ApiResponse({ status: 200, description: 'Tokens refreshed successfully' }) @ApiResponse({ status: 401, description: 'Invalid or expired refresh token' }) @UseGuards(RefreshTokenAuthGuard) + @HttpCode(HttpStatus.OK) @Post('refresh') async refresh( @Request() req: any, @@ -111,6 +115,7 @@ export class AuthController { @ApiResponse({ status: 200, description: 'Successfully logged out' }) @ApiResponse({ status: 401, description: 'Invalid refresh token' }) @UseGuards(RefreshTokenAuthGuard) + @HttpCode(HttpStatus.OK) @Post('logout') async logout(@Request() req: any, @Res({ passthrough: true }) res: Response) { await this.authService.revokeRefreshToken(req.user.refreshToken); @@ -127,6 +132,7 @@ export class AuthController { description: 'If an account with that email exists, a password reset link has been sent', }) + @HttpCode(HttpStatus.OK) @Post('forgot-password') async forgotPassword(@Body() forgotPasswordDto: ForgotPasswordDto) { return this.authService.requestPasswordReset(forgotPasswordDto.email); @@ -136,6 +142,7 @@ export class AuthController { @ApiBody({ type: ResetPasswordDto }) @ApiResponse({ status: 200, description: 'Password reset successfully' }) @ApiResponse({ status: 400, description: 'Invalid or expired token' }) + @HttpCode(HttpStatus.OK) @Post('reset-password') async resetPassword(@Body() resetPasswordDto: ResetPasswordDto) { const { token, newPassword } = resetPasswordDto; @@ -149,6 +156,7 @@ export class AuthController { @ApiResponse({ status: 400, description: 'Current password is incorrect' }) @ApiResponse({ status: 401, description: 'Unauthorized' }) @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) @Post('change-password') async changePassword( @Request() req: any, diff --git a/backend/test/auth-password-reset.e2e-spec.ts b/backend/test/auth-password-reset.e2e-spec.ts index a558dc3..5b89d85 100644 --- a/backend/test/auth-password-reset.e2e-spec.ts +++ b/backend/test/auth-password-reset.e2e-spec.ts @@ -53,7 +53,7 @@ describe('Auth - Password Reset (e2e)', () => { username: 'testuser', password: 'password123', }) - .expect(201); + .expect(200); const setCookies = loginResponse.headers[ 'set-cookie' @@ -79,7 +79,7 @@ describe('Auth - Password Reset (e2e)', () => { const response = await request(app.getHttpServer()) .post('/auth/forgot-password') .send({ email: 'test@example.com' }) - .expect(201); + .expect(200); expect(response.body).toHaveProperty('message'); expect(response.body.message).toContain( @@ -102,7 +102,7 @@ describe('Auth - Password Reset (e2e)', () => { const response = await request(app.getHttpServer()) .post('/auth/forgot-password') .send({ email: 'nonexistent@example.com' }) - .expect(201); + .expect(200); expect(response.body.message).toContain( 'If an account with that email exists', @@ -148,7 +148,7 @@ describe('Auth - Password Reset (e2e)', () => { token: validToken, newPassword, }) - .expect(201); + .expect(200); expect(response.body.message).toContain('reset successfully'); @@ -167,7 +167,7 @@ describe('Auth - Password Reset (e2e)', () => { username: 'testuser', password: newPassword, }) - .expect(201); + .expect(200); // Reset password back for other tests const hashedPassword = await bcrypt.hash('password123', 10); @@ -264,7 +264,7 @@ describe('Auth - Password Reset (e2e)', () => { currentPassword, newPassword, }) - .expect(201); + .expect(200); expect(response.body.message).toContain('changed successfully'); @@ -275,7 +275,7 @@ describe('Auth - Password Reset (e2e)', () => { username: 'testuser', password: newPassword, }) - .expect(201); + .expect(200); // Reset password back for other tests const hashedPassword = await bcrypt.hash('password123', 10); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7310b1e..a3bf269 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,8 +87,8 @@ importers: specifier: ^1.8.0 version: 1.9.4 joi: - specifier: ^18.1.2 - version: 18.1.2 + specifier: ^17.13.3 + version: 17.13.3 passport: specifier: ^0.7.0 version: 0.7.0 @@ -716,25 +716,11 @@ packages: resolution: {integrity: sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@hapi/address@5.1.1': - resolution: {integrity: sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==} - engines: {node: '>=14.0.0'} - - '@hapi/formula@3.0.2': - resolution: {integrity: sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==} - - '@hapi/hoek@11.0.7': - resolution: {integrity: sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==} - - '@hapi/pinpoint@2.0.1': - resolution: {integrity: sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==} - - '@hapi/tlds@1.1.6': - resolution: {integrity: sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw==} - engines: {node: '>=14.0.0'} + '@hapi/hoek@9.3.0': + resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} - '@hapi/topo@6.0.2': - resolution: {integrity: sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==} + '@hapi/topo@5.1.0': + resolution: {integrity: sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==} '@humanwhocodes/config-array@0.13.0': resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} @@ -1308,6 +1294,15 @@ packages: cpu: [x64] os: [win32] + '@sideway/address@4.1.5': + resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==} + + '@sideway/formula@3.0.1': + resolution: {integrity: sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==} + + '@sideway/pinpoint@2.0.0': + resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==} + '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} @@ -1320,9 +1315,6 @@ packages: '@sqltools/formatter@1.2.5': resolution: {integrity: sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==} - '@standard-schema/spec@1.1.0': - resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@testing-library/dom@9.3.4': resolution: {integrity: sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==} engines: {node: '>=14'} @@ -3047,9 +3039,8 @@ packages: node-notifier: optional: true - joi@18.1.2: - resolution: {integrity: sha512-rF5MAmps5esSlhCA+N1b6IYHDw9j/btzGaqfgie522jS02Ju/HXBxamlXVlKEHAxoMKQL77HWI8jlqWsFuekZA==} - engines: {node: '>= 20'} + joi@17.13.3: + resolution: {integrity: sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==} js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -4985,21 +4976,11 @@ snapshots: '@eslint/js@9.39.1': {} - '@hapi/address@5.1.1': - dependencies: - '@hapi/hoek': 11.0.7 - - '@hapi/formula@3.0.2': {} - - '@hapi/hoek@11.0.7': {} - - '@hapi/pinpoint@2.0.1': {} - - '@hapi/tlds@1.1.6': {} + '@hapi/hoek@9.3.0': {} - '@hapi/topo@6.0.2': + '@hapi/topo@5.1.0': dependencies: - '@hapi/hoek': 11.0.7 + '@hapi/hoek': 9.3.0 '@humanwhocodes/config-array@0.13.0': dependencies: @@ -5636,6 +5617,14 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.53.2': optional: true + '@sideway/address@4.1.5': + dependencies: + '@hapi/hoek': 9.3.0 + + '@sideway/formula@3.0.1': {} + + '@sideway/pinpoint@2.0.0': {} + '@sinclair/typebox@0.27.8': {} '@sinonjs/commons@3.0.1': @@ -5648,8 +5637,6 @@ snapshots: '@sqltools/formatter@1.2.5': {} - '@standard-schema/spec@1.1.0': {} - '@testing-library/dom@9.3.4': dependencies: '@babel/code-frame': 7.27.1 @@ -7813,15 +7800,13 @@ snapshots: - supports-color - ts-node - joi@18.1.2: + joi@17.13.3: dependencies: - '@hapi/address': 5.1.1 - '@hapi/formula': 3.0.2 - '@hapi/hoek': 11.0.7 - '@hapi/pinpoint': 2.0.1 - '@hapi/tlds': 1.1.6 - '@hapi/topo': 6.0.2 - '@standard-schema/spec': 1.1.0 + '@hapi/hoek': 9.3.0 + '@hapi/topo': 5.1.0 + '@sideway/address': 4.1.5 + '@sideway/formula': 3.0.1 + '@sideway/pinpoint': 2.0.0 js-tokens@4.0.0: {} From 5b731fddbaa0327d8ad8e7b2d0515e8e0d84f6e7 Mon Sep 17 00:00:00 2001 From: Demian Date: Mon, 13 Apr 2026 01:29:31 -0400 Subject: [PATCH 7/9] fix: remove JWT_REFRESH_SECRET from env example and test env Aligns docs with the Joi schema which no longer validates this var. Refresh tokens are opaque DB strings, not JWTs, so there is no refresh secret to configure. --- backend/.env.example | 1 - backend/.env.test | 1 - 2 files changed, 2 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index b00d799..09f99ca 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -6,7 +6,6 @@ APP_NAME=STATION BACKEND # ─── JWT ──────────────────────────────────────────────────────────────────────── # Required. Minimum 32 characters. Use a strong random value in production. JWT_SECRET=your-super-secret-jwt-key-minimum-32-chars -JWT_REFRESH_SECRET=your-super-secret-refresh-key-minimum-32-chars # ─── Database ─────────────────────────────────────────────────────────────────── DATABASE_HOST=localhost diff --git a/backend/.env.test b/backend/.env.test index 80ca9b8..e98bd8b 100644 --- a/backend/.env.test +++ b/backend/.env.test @@ -4,7 +4,6 @@ DATABASE_USER=stationDbUser DATABASE_PASSWORD=stationDbPassword1 DATABASE_NAME=stationDb JWT_SECRET=test-jwt-secret-for-e2e-tests-only-32chars -JWT_REFRESH_SECRET=test-jwt-refresh-secret-for-e2e-tests-32chars PORT=3000 APP_NAME=STATION BACKEND TEST USE_REDIS_CACHE=false From a8b865d329db8699044a8a24537a25ac4a37f1f0 Mon Sep 17 00:00:00 2001 From: Demian Date: Mon, 13 Apr 2026 09:18:44 -0400 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20address=20PR118=20review=20comments?= =?UTF-8?q?=20=E2=80=94=20Joi=20production=20guards,=20ConfigService,=20ty?= =?UTF-8?q?pe=20safety?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Make ALLOWED_ORIGIN and FRONTEND_URL required via Joi when('NODE_ENV') conditional so production deployments fail fast at startup if either is unset, while keeping localhost defaults for dev/test - Inject ConfigService into AuthController and use it in cookieOptions() instead of process.env.NODE_ENV so cookie security flags come from the same validated config source as the rest of the app - Fix LocalStrategy.validate() return type: Omit → Omit (matches what validateUser() actually returns); replace the indirect Parameters<> cast in the controller with the direct Omit type --- backend/src/config/env.validation.ts | 18 +++++++++++++++--- backend/src/modules/auth/auth.controller.ts | 11 ++++++++--- backend/src/modules/auth/local.strategy.ts | 4 ++-- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/backend/src/config/env.validation.ts b/backend/src/config/env.validation.ts index 3134eab..60b4357 100644 --- a/backend/src/config/env.validation.ts +++ b/backend/src/config/env.validation.ts @@ -23,9 +23,21 @@ export const envValidationSchema = Joi.object({ REDIS_PORT: Joi.number().default(6379), USE_REDIS_CACHE: Joi.string().valid('true', 'false').default('true'), - // CORS / Frontend - ALLOWED_ORIGIN: Joi.string().uri().default('http://localhost:5173'), - FRONTEND_URL: Joi.string().uri().default('http://localhost:5173'), + // CORS / Frontend — required in production; default to localhost in dev/test + ALLOWED_ORIGIN: Joi.string() + .uri() + .when('NODE_ENV', { + is: 'production', + then: Joi.required(), + otherwise: Joi.default('http://localhost:5173'), + }), + FRONTEND_URL: Joi.string() + .uri() + .when('NODE_ENV', { + is: 'production', + then: Joi.required(), + otherwise: Joi.default('http://localhost:5173'), + }), // UEX Sync (all optional — service degrades gracefully when disabled) // Use Joi.boolean() so env strings like 'false' are coerced to actual diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index c07cb4e..5f8e471 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -15,11 +15,13 @@ import { ApiBody, ApiBearerAuth, } from '@nestjs/swagger'; +import { ConfigService } from '@nestjs/config'; import { AuthService } from './auth.service'; import { LocalAuthGuard } from './local-auth.guard'; import { JwtAuthGuard } from './jwt-auth.guard'; import { RefreshTokenAuthGuard } from './refresh-token-auth.guard'; import { UserDto } from '../users/dto/user.dto'; +import { User } from '../users/user.entity'; import { Request as ExpressRequest, Response } from 'express'; import { ChangePasswordDto, @@ -30,12 +32,15 @@ import { @ApiTags('auth') @Controller('auth') export class AuthController { - constructor(private authService: AuthService) {} + constructor( + private authService: AuthService, + private configService: ConfigService, + ) {} private cookieOptions(maxAge: number) { return { httpOnly: true, - secure: process.env.NODE_ENV === 'production', + secure: this.configService.get('NODE_ENV') === 'production', sameSite: 'strict' as const, path: '/', maxAge, @@ -61,7 +66,7 @@ export class AuthController { @Request() req: ExpressRequest, @Res({ passthrough: true }) res: Response, ) { - const user = req.user as Parameters[0]; + const user = req.user as Omit; const tokens = await this.authService.login(user); res.cookie( 'access_token', diff --git a/backend/src/modules/auth/local.strategy.ts b/backend/src/modules/auth/local.strategy.ts index 27ddb0a..c4f431c 100644 --- a/backend/src/modules/auth/local.strategy.ts +++ b/backend/src/modules/auth/local.strategy.ts @@ -2,7 +2,7 @@ import { Strategy } from 'passport-local'; import { PassportStrategy } from '@nestjs/passport'; import { Injectable, UnauthorizedException, Logger } from '@nestjs/common'; import { AuthService } from './auth.service'; -import { UserDto } from '../users/dto/user.dto'; +import { User } from '../users/user.entity'; @Injectable() export class LocalStrategy extends PassportStrategy(Strategy) { @@ -18,7 +18,7 @@ export class LocalStrategy extends PassportStrategy(Strategy) { async validate( username: string, password: string, - ): Promise> { + ): Promise> { this.logger.debug(`Validating user: ${username}`); const user = await this.authService.validateUser(username, password); From 58aea71e91ace2cff7cd7d0c4b09720a380ed3d4 Mon Sep 17 00:00:00 2001 From: Demian Date: Mon, 13 Apr 2026 09:28:46 -0400 Subject: [PATCH 9/9] fix: correct Joi.default() usage and add ConfigService to auth unit test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace Joi.default() root calls (not valid in joi@17) with Joi.string().uri().default() in the otherwise clause of Joi.when() - Add ConfigService mock provider to auth.controller.spec.ts — required after AuthController gained a ConfigService constructor dependency --- backend/src/config/env.validation.ts | 4 ++-- backend/src/modules/auth/auth.controller.spec.ts | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/backend/src/config/env.validation.ts b/backend/src/config/env.validation.ts index 60b4357..4116460 100644 --- a/backend/src/config/env.validation.ts +++ b/backend/src/config/env.validation.ts @@ -29,14 +29,14 @@ export const envValidationSchema = Joi.object({ .when('NODE_ENV', { is: 'production', then: Joi.required(), - otherwise: Joi.default('http://localhost:5173'), + otherwise: Joi.string().uri().default('http://localhost:5173'), }), FRONTEND_URL: Joi.string() .uri() .when('NODE_ENV', { is: 'production', then: Joi.required(), - otherwise: Joi.default('http://localhost:5173'), + otherwise: Joi.string().uri().default('http://localhost:5173'), }), // UEX Sync (all optional — service degrades gracefully when disabled) diff --git a/backend/src/modules/auth/auth.controller.spec.ts b/backend/src/modules/auth/auth.controller.spec.ts index 12cc63d..2121759 100644 --- a/backend/src/modules/auth/auth.controller.spec.ts +++ b/backend/src/modules/auth/auth.controller.spec.ts @@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { BadRequestException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; describe('AuthController - Password Reset', () => { let controller: AuthController; @@ -25,6 +26,12 @@ describe('AuthController - Password Reset', () => { provide: AuthService, useValue: mockAuthService, }, + { + provide: ConfigService, + useValue: { + get: jest.fn().mockReturnValue('test'), + }, + }, ], }).compile();