diff --git a/backend/package.json b/backend/package.json index 9160fc9..7f3b7f6 100644 --- a/backend/package.json +++ b/backend/package.json @@ -50,11 +50,11 @@ "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", "helmet": "^8.0.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", @@ -68,11 +68,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 38e712b..fac16cf 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -6,6 +6,7 @@ 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 helmet from 'helmet'; import { HttpExceptionFilter } from './common/filters/http-exception.filter'; @@ -34,6 +35,9 @@ async function bootstrap() { // ASCII Art for Application Name console.log(figlet.textSync(appName, { horizontalLayout: 'full' })); + // Cookie parser — must be registered before guards that read cookies + app.use(cookieParser()); + // Security headers — Swagger UI requires 'unsafe-inline' for scripts/styles, // but Swagger is disabled in production so production uses a strict CSP. // frameguard and hsts are set explicitly to meet security requirements. @@ -110,16 +114,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); @@ -129,7 +123,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 (!isProduction) { diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index 080e06a..7f7e90e 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -1,4 +1,14 @@ -import { Controller, Post, UseGuards, Request, Body } from '@nestjs/common'; +import { + Controller, + Get, + Post, + UseGuards, + Request, + Body, + Res, + HttpCode, + HttpStatus, +} from '@nestjs/common'; import { ApiTags, ApiOperation, @@ -11,7 +21,8 @@ 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 { User } from '../users/user.entity'; +import { Request as ExpressRequest, Response } from 'express'; import { ChangePasswordDto, ForgotPasswordDto, @@ -23,6 +34,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: { @@ -36,9 +57,25 @@ 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) { - return this.authService.login(req.user); + async login( + @Request() req: ExpressRequest, + @Res({ passthrough: true }) res: Response, + ) { + const user = req.user as Omit; + const tokens = await this.authService.login(user); + 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: 'Login successful', username: user.username }; } @ApiOperation({ summary: 'Register new user' }) @@ -50,26 +87,53 @@ export class AuthController { return this.authService.register(userDto); } - @ApiOperation({ summary: 'Refresh access token using refresh token' }) - @ApiBearerAuth('refresh-token') + @ApiOperation({ summary: 'Get current authenticated user' }) + @ApiBearerAuth('access-token') + @ApiResponse({ status: 200, description: 'Current user info' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @UseGuards(JwtAuthGuard) + @Get('me') + me(@Request() req: any) { + return { id: req.user.userId, username: req.user.username }; + } + + @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) + @HttpCode(HttpStatus.OK) @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, + ); + 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' }; } @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) + @HttpCode(HttpStatus.OK) @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); + const { maxAge: _maxAge, ...clearOpts } = this.cookieOptions(0); + res.clearCookie('access_token', clearOpts); + res.clearCookie('refresh_token', clearOpts); return { message: 'Logged out successfully' }; } @@ -80,6 +144,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); @@ -89,6 +154,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; @@ -96,12 +162,13 @@ 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' }) @ApiResponse({ status: 401, description: 'Unauthorized' }) @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) @Post('change-password') async changePassword( @Request() req: any, 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 23f03ca..edfaf37 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> { @@ -101,7 +98,7 @@ export class AuthService { async refreshAccessToken( refreshToken: string, - ): Promise<{ access_token: string; refresh_token: string }> { + ): Promise<{ accessToken: string; refreshToken: string }> { const storedToken = await this.refreshTokenRepository.findOne({ where: { token: this.hashToken(refreshToken) }, relations: ['user'], @@ -130,8 +127,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..97a4646 100644 --- a/backend/src/modules/auth/jwt.strategy.ts +++ b/backend/src/modules/auth/jwt.strategy.ts @@ -2,18 +2,24 @@ 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([ + // Prefer httpOnly cookie (browser clients) + (req: Request) => req?.cookies?.access_token ?? null, + // Fallback to Authorization: Bearer header (Swagger / API clients) + ExtractJwt.fromAuthHeaderAsBearerToken(), + ]), 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/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); 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/backend/test/auth-password-reset.e2e-spec.ts b/backend/test/auth-password-reset.e2e-spec.ts index 77b5def..223fb75 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, @@ -51,9 +53,17 @@ describe('Auth - Password Reset (e2e)', () => { username: 'testuser', password: 'password123', }) - .expect(201); - - accessToken = loginResponse.body.access_token; + .expect(200); + + const setCookies = loginResponse.headers[ + 'set-cookie' + ] as unknown as string[]; + expect(Array.isArray(setCookies)).toBe(true); + const accessTokenCookie = setCookies.find((c) => + c.startsWith('access_token='), + ); + expect(accessTokenCookie).toBeDefined(); + authCookie = accessTokenCookie!.split(';')[0]; }); afterAll(async () => { @@ -72,7 +82,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( @@ -95,7 +105,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', @@ -141,7 +151,7 @@ describe('Auth - Password Reset (e2e)', () => { token: validToken, newPassword, }) - .expect(201); + .expect(200); expect(response.body.message).toContain('reset successfully'); @@ -160,7 +170,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); @@ -252,12 +262,12 @@ 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, }) - .expect(201); + .expect(200); expect(response.body.message).toContain('changed successfully'); @@ -268,7 +278,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); @@ -279,7 +289,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 +310,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 +321,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 +331,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 +341,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..af56464 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(); @@ -42,9 +44,18 @@ describe('Organizations (e2e)', () => { .send({ username: 'orguser', password: 'password123', - }); - - authToken = loginResponse.body.access_token; + }) + .expect(200); + + const setCookies = loginResponse.headers[ + 'set-cookie' + ] as unknown as string[]; + expect(Array.isArray(setCookies)).toBe(true); + const accessTokenCookie = setCookies.find((c) => + c.startsWith('access_token='), + ); + expect(accessTokenCookie).toBeDefined(); + authCookie = accessTokenCookie!.split(';')[0]; }); afterAll(async () => { @@ -55,7 +66,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 +94,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 +110,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 +123,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 +134,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 +143,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 +157,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 +173,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 +188,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..3321916 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(); @@ -35,9 +37,18 @@ describe('Roles (e2e)', () => { .send({ username: 'testuser', password: 'password123', - }); - - authToken = loginResponse.body.access_token; + }) + .expect(200); + + const setCookies = loginResponse.headers[ + 'set-cookie' + ] as unknown as string[]; + expect(Array.isArray(setCookies)).toBe(true); + const accessTokenCookie = setCookies.find((c) => + c.startsWith('access_token='), + ); + expect(accessTokenCookie).toBeDefined(); + authCookie = accessTokenCookie!.split(';')[0]; }); afterAll(async () => { @@ -48,7 +59,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 +84,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 +106,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 +119,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 +130,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 +139,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 +160,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..d0e324a 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(); @@ -33,44 +35,60 @@ 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', + }) + .expect(201); + + expect(registerResponse.body.id).toBeDefined(); + userId = registerResponse.body.id; const loginResponse = await request(app.getHttpServer()) .post('/auth/login') .send({ username: 'roleuser', password: 'password123', - }); + }) + .expect(200); - authToken = loginResponse.body.access_token; - userId = loginResponse.body.userId || 1; + const setCookies = loginResponse.headers[ + 'set-cookie' + ] as unknown as string[]; + expect(Array.isArray(setCookies)).toBe(true); + const accessTokenCookie = setCookies.find((c) => + c.startsWith('access_token='), + ); + expect(accessTokenCookie).toBeDefined(); + authCookie = accessTokenCookie!.split(';')[0]; // 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', - }); + }) + .expect(201); organizationId = orgResponse.body.id; // Create a test role const roleResponse = await request(app.getHttpServer()) .post('/roles') - .set('Authorization', `Bearer ${authToken}`) + .set('Cookie', authCookie) .send({ name: 'Test Role', permissions: { canEdit: true, canDelete: false, }, - }); + }) + .expect(201); roleId = roleResponse.body.id; }); @@ -83,7 +101,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 +119,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 +131,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 +146,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 +154,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 +162,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 +182,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 +196,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 +211,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 +228,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 +244,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 +253,7 @@ describe('UserOrganizationRoles (e2e)', () => { .delete( `/user-organization-roles/user/${userId}/organization/${organizationId}/role/99999`, ) - .set('Authorization', `Bearer ${authToken}`) + .set('Cookie', authCookie) .expect(404); }); }); @@ -245,16 +263,17 @@ 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, roleId, - }); + }) + .expect(201); 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'); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bfc249e..d5c8f45 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 @@ -89,9 +92,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 @@ -126,6 +126,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 @@ -138,9 +141,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 @@ -1340,9 +1340,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==} @@ -1367,15 +1364,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==} @@ -1400,9 +1396,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==} @@ -1427,15 +1420,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==} @@ -1454,9 +1438,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==} @@ -2064,6 +2045,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==} @@ -2071,6 +2056,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==} @@ -3452,10 +3441,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==} @@ -5675,10 +5660,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': @@ -5715,16 +5696,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: @@ -5760,8 +5736,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': {} @@ -5790,23 +5764,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': {} @@ -5821,12 +5778,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 @@ -6548,10 +6499,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: {} @@ -8192,10 +8150,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