diff --git a/package-lock.json b/package-lock.json index 8d2fbe3..8a7bebd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "bullmq": "^1.73.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", + "cookie-parser": "^1.4.7", "dotenv": "^16.6.1", "ioredis": "^5.3.2", "jose": "^6.2.6", @@ -43,6 +44,7 @@ "@nestjs/testing": "^10.4.22", "@playwright/test": "^1.62.1", "@types/bcrypt": "^5.0.2", + "@types/cookie-parser": "^1.4.10", "@types/jest": "^29.5.14", "@types/node": "^22.20.1", "@types/passport-jwt": "^4.0.1", @@ -2260,6 +2262,16 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cookiejar": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", @@ -3667,6 +3679,25 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", diff --git a/package.json b/package.json index 63a581e..9558769 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "bullmq": "^1.73.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", + "cookie-parser": "^1.4.7", "dotenv": "^16.6.1", "ioredis": "^5.3.2", "jose": "^6.2.6", @@ -59,6 +60,7 @@ "@nestjs/testing": "^10.4.22", "@playwright/test": "^1.62.1", "@types/bcrypt": "^5.0.2", + "@types/cookie-parser": "^1.4.10", "@types/jest": "^29.5.14", "@types/node": "^22.20.1", "@types/passport-jwt": "^4.0.1", @@ -78,6 +80,6 @@ "typescript": "^5.9.3" }, "prisma": { - "seed": "node --loader ts-node/esm prisma/seed.ts" + "seed": "node --loader ts-node/esm prisma/seed.ts" } } diff --git a/src/core/auth/guards/jwt.guard.ts b/src/core/auth/guards/jwt.guard.ts new file mode 100644 index 0000000..0b7a1b5 --- /dev/null +++ b/src/core/auth/guards/jwt.guard.ts @@ -0,0 +1,22 @@ +import { Injectable, Logger, ExecutionContext } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class JwtGuard extends AuthGuard('jwt') { + private readonly logger = new Logger('JwtGuard'); + + canActivate(context: ExecutionContext) { + const req = context.switchToHttp().getRequest(); + this.logger.debug(`JwtGuard invoked. cookies=${JSON.stringify(req.cookies)} authHeader=${req.headers?.authorization}`); + return super.canActivate(context); + } + + handleRequest(err: any, user: any, info: any) { + if (err || !user) { + this.logger.debug(`JwtGuard handleRequest failed. err=${err} user=${!!user} info=${JSON.stringify(info)}`); + } else { + this.logger.debug(`JwtGuard handleRequest success user=${JSON.stringify(user)}`); + } + return super.handleRequest(err, user, info); + } +} diff --git a/src/core/auth/guards/refresh.guard.ts b/src/core/auth/guards/refresh.guard.ts new file mode 100644 index 0000000..817a0c9 --- /dev/null +++ b/src/core/auth/guards/refresh.guard.ts @@ -0,0 +1,22 @@ +import { Injectable, Logger, ExecutionContext } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class RefreshGuard extends AuthGuard('refresh') { + private readonly logger = new Logger('RefreshGuard'); + + canActivate(context: ExecutionContext) { + const req = context.switchToHttp().getRequest(); + this.logger.debug(`RefreshGuard invoked. cookies=${JSON.stringify(req.cookies)} body=${JSON.stringify(req.body)}`); + return super.canActivate(context); + } + + handleRequest(err: any, user: any, info: any) { + if (err || !user) { + this.logger.debug(`RefreshGuard handleRequest failed. err=${err} user=${!!user} info=${JSON.stringify(info)}`); + } else { + this.logger.debug(`RefreshGuard handleRequest success userId=${user.id} authInfo=${JSON.stringify(info)}`); + } + return super.handleRequest(err, user, info); + } +} diff --git a/src/core/auth/strategies/jwt.strategy.ts b/src/core/auth/strategies/jwt.strategy.ts new file mode 100644 index 0000000..8ed4df9 --- /dev/null +++ b/src/core/auth/strategies/jwt.strategy.ts @@ -0,0 +1,29 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy as JwtStrategyBase, ExtractJwt } from 'passport-jwt'; +import { ConfigService } from '@nestjs/config'; + +function cookieExtractor(req: any): string | null { + if (!req) return null; + if (req.cookies && req.cookies.raylab_jwt) return req.cookies.raylab_jwt as string; + return null; +} + +@Injectable() +export class JwtStrategy extends PassportStrategy(JwtStrategyBase, 'jwt') { + private readonly logger = new Logger('JwtStrategy'); + + constructor(private config: ConfigService) { + super({ + jwtFromRequest: ExtractJwt.fromExtractors([cookieExtractor, ExtractJwt.fromAuthHeaderAsBearerToken()]), + secretOrKey: config.get('RAYLAB_JWT_SECRET') || process.env.RAYLAB_JWT_SECRET || 'raylab-secret', + algorithms: ['HS256'], + }); + this.logger.debug('JwtStrategy initialized'); + } + + async validate(payload: any) { + this.logger.debug(`JwtStrategy.validate payload=${JSON.stringify(payload)}`); + return payload; // attached to req.user + } +} diff --git a/src/core/auth/strategies/refresh.strategy.ts b/src/core/auth/strategies/refresh.strategy.ts new file mode 100644 index 0000000..0d51b30 --- /dev/null +++ b/src/core/auth/strategies/refresh.strategy.ts @@ -0,0 +1,58 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy } from 'passport-strategy'; +import { InMemoryRefreshStore } from '../../../modules/auth/refresh/inmemory-refresh.store'; +import { IUser } from '../../../modules/identity/domain/repositories/user.interface'; +import { Inject } from '@nestjs/common'; + +// Minimal Passport Strategy for refresh tokens (non-JWT random tokens) +class RefreshTokenStrategy extends Strategy { + name = 'refresh'; + authenticate(req: any) { + // This will be overridden in PassportStrategy wrapper + this.error(new Error('Not implemented')); + } +} + +@Injectable() +export class RefreshStrategy extends PassportStrategy(RefreshTokenStrategy, 'refresh') { + private readonly logger = new Logger('RefreshStrategy'); + + constructor(private refreshStore: InMemoryRefreshStore, @Inject(IUser) private userRepository: IUser) { + super(); + this.logger.debug('RefreshStrategy initialized'); + } + + async authenticate(req: any, options?: any) { + const cookies = req.cookies || null; + const token = (cookies && cookies.raylab_refresh) || (req.body && req.body.refreshToken); + this.logger.debug(`RefreshStrategy.authenticate cookies=${JSON.stringify(cookies)} tokenExtracted=${!!token}`); + + if (!token) { + this.logger.debug('RefreshStrategy: no refresh token provided'); + return this.fail('Missing refresh token', 401); + } + + try { + const data = await this.refreshStore.get(token); + if (!data) { + this.logger.debug('RefreshStrategy: refresh token not found/expired'); + return this.fail('Invalid refresh token', 401); + } + + const user = await this.userRepository.getById(data.userId); + if (!user) { + this.logger.debug('RefreshStrategy: user not found for refresh token'); + return this.fail('User not found', 401); + } + + // success - attach user + token info + const info = { refreshToken: token, userId: data.userId }; + this.logger.debug(`RefreshStrategy: validated refresh token for userId=${data.userId}`); + return this.success(user, info); + } catch (e) { + this.logger.debug(`RefreshStrategy error: ${(e as Error).message}`); + return this.error(e as Error); + } + } +} diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 8efcb12..cfbc36b 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -1,8 +1,10 @@ -import { Controller, Get, Post, Query, Res, Req, Body, HttpCode, HttpStatus, Logger } from '@nestjs/common'; +import { Controller, Get, Post, Query, Res, Req, Body, HttpCode, HttpStatus, Logger, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { AuthService } from './auth.service'; import { Response, Request } from 'express'; +import { JwtGuard } from '../../../core/auth/guards/jwt.guard'; +import { RefreshGuard } from '../../../core/auth/guards/refresh.guard'; import { ConfigService } from '@nestjs/config'; @ApiTags('Auth') @@ -56,21 +58,43 @@ export class AuthController { } @Post('refresh') + @UseGuards(RefreshGuard) @ApiOperation({ summary: 'Refresh internal JWT using internal refresh token' }) - async refresh(@Req() req: Request) { - const refreshToken = req.cookies?.raylab_refresh || req.body?.refreshToken; - this.logger.debug(`Refresh called. cookies present=${!!req.cookies} refreshTokenProvided=${!!refreshToken}`); + async refresh(@Req() req: Request, @Res() res: Response) { + // req.authInfo should contain { refreshToken } + const refreshToken = (req as any).authInfo?.refreshToken || (req.cookies?.raylab_refresh) || (req.body?.refreshToken); + this.logger.debug(`Refresh called (guarded). cookies=${JSON.stringify((req as any).cookies)} authInfo=${JSON.stringify((req as any).authInfo)}`); + const result = await this.authService.refresh(refreshToken); - this.logger.debug(`Refresh result for user. expiresIn=${result.expiresIn}`); - return { success: true, data: result }; + + // set cookies with environment-aware options + const isProd = this.config.get('NODE_ENV') === 'production' || process.env.NODE_ENV === 'production'; + const cookieDomain = this.config.get('RAYLAB_COOKIE_DOMAIN') || (isProd ? '.raylab.site' : undefined); + + const cookieOptions: any = { + httpOnly: true, + secure: isProd, + sameSite: isProd ? 'none' : 'lax', + path: '/', + }; + if (cookieDomain) cookieOptions.domain = cookieDomain; + + // set new cookies (rotate) + res.cookie('raylab_jwt', result.accessToken, { ...cookieOptions, maxAge: result.expiresIn * 1000 }); + res.cookie('raylab_refresh', result.refreshToken, { ...cookieOptions, maxAge: result.refreshTtl * 1000 }); + + this.logger.debug(`Refresh complete. set new cookies for user`); + return res.json({ success: true, data: result }); } @Get('me') + @UseGuards(JwtGuard) @ApiOperation({ summary: 'Get current user from internal JWT (cookie or Authorization header)' }) async me(@Req() req: Request) { - const token = (req.cookies?.raylab_jwt) || (req.headers.authorization && (req.headers.authorization as string).replace(/^Bearer\s+/i, '')); - this.logger.debug(`Me called. cookies present=${!!req.cookies} tokenProvided=${!!token}`); - const user = await this.authService.me(token); + // With JwtGuard, req.user should be payload from token + this.logger.debug(`Me called (guarded). cookies=${JSON.stringify((req as any).cookies)} user=${JSON.stringify((req as any).user)}`); + const payload = (req as any).user; + const user = await this.authService.me((req.cookies?.raylab_jwt) || (req.headers.authorization && (req.headers.authorization as string).replace(/^Bearer\s+/i, ''))); this.logger.debug(`Me returning user id=${user?.id}`); return { success: true, data: user }; } diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 0e02aff..5f16b6f 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -1,4 +1,9 @@ import { Module } from '@nestjs/common'; +import { PassportModule } from '@nestjs/passport'; +import { JwtStrategy } from '../../core/auth/strategies/jwt.strategy'; +import { RefreshStrategy } from '../../core/auth/strategies/refresh.strategy'; +import { JwtGuard } from '../../core/auth/guards/jwt.guard'; +import { RefreshGuard } from '../../core/auth/guards/refresh.guard'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { ConfigModule, ConfigService } from '@nestjs/config'; @@ -27,6 +32,7 @@ import { AuthorizationModule } from '../authorization/authorization.module'; imports: [ ConfigModule, AuthorizationModule, + PassportModule.register({ defaultStrategy: 'jwt', session: false }), JwtModule.registerAsync({ imports: [ConfigModule], useFactory: async (config: ConfigService) => ({ @@ -59,6 +65,12 @@ import { AuthorizationModule } from '../authorization/authorization.module'; EventBus, AuditService, RedisService, + + // Strategies & Guards + JwtStrategy, + RefreshStrategy, + JwtGuard, + RefreshGuard, ], exports: [AuthService, OidcService, RoleSyncService, AuthenticationService, EventBus], })