Generated
+31
@@ -24,6 +24,7 @@
|
|||||||
"bullmq": "^1.73.0",
|
"bullmq": "^1.73.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.2",
|
"class-validator": "^0.14.2",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
"jose": "^6.2.6",
|
"jose": "^6.2.6",
|
||||||
@@ -43,6 +44,7 @@
|
|||||||
"@nestjs/testing": "^10.4.22",
|
"@nestjs/testing": "^10.4.22",
|
||||||
"@playwright/test": "^1.62.1",
|
"@playwright/test": "^1.62.1",
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
"@types/node": "^22.20.1",
|
"@types/node": "^22.20.1",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
@@ -2260,6 +2262,16 @@
|
|||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/cookiejar": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
|
||||||
@@ -3667,6 +3679,25 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/cookie-signature": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||||
|
|||||||
+3
-1
@@ -40,6 +40,7 @@
|
|||||||
"bullmq": "^1.73.0",
|
"bullmq": "^1.73.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.2",
|
"class-validator": "^0.14.2",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
"jose": "^6.2.6",
|
"jose": "^6.2.6",
|
||||||
@@ -59,6 +60,7 @@
|
|||||||
"@nestjs/testing": "^10.4.22",
|
"@nestjs/testing": "^10.4.22",
|
||||||
"@playwright/test": "^1.62.1",
|
"@playwright/test": "^1.62.1",
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
"@types/node": "^22.20.1",
|
"@types/node": "^22.20.1",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
@@ -78,6 +80,6 @@
|
|||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "node --loader ts-node/esm prisma/seed.ts"
|
"seed": "node --loader ts-node/esm prisma/seed.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string>('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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { Response, Request } from 'express';
|
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';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
@ApiTags('Auth')
|
@ApiTags('Auth')
|
||||||
@@ -56,21 +58,43 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('refresh')
|
@Post('refresh')
|
||||||
|
@UseGuards(RefreshGuard)
|
||||||
@ApiOperation({ summary: 'Refresh internal JWT using internal refresh token' })
|
@ApiOperation({ summary: 'Refresh internal JWT using internal refresh token' })
|
||||||
async refresh(@Req() req: Request) {
|
async refresh(@Req() req: Request, @Res() res: Response) {
|
||||||
const refreshToken = req.cookies?.raylab_refresh || req.body?.refreshToken;
|
// req.authInfo should contain { refreshToken }
|
||||||
this.logger.debug(`Refresh called. cookies present=${!!req.cookies} refreshTokenProvided=${!!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);
|
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<string>('NODE_ENV') === 'production' || process.env.NODE_ENV === 'production';
|
||||||
|
const cookieDomain = this.config.get<string>('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')
|
@Get('me')
|
||||||
|
@UseGuards(JwtGuard)
|
||||||
@ApiOperation({ summary: 'Get current user from internal JWT (cookie or Authorization header)' })
|
@ApiOperation({ summary: 'Get current user from internal JWT (cookie or Authorization header)' })
|
||||||
async me(@Req() req: Request) {
|
async me(@Req() req: Request) {
|
||||||
const token = (req.cookies?.raylab_jwt) || (req.headers.authorization && (req.headers.authorization as string).replace(/^Bearer\s+/i, ''));
|
// With JwtGuard, req.user should be payload from token
|
||||||
this.logger.debug(`Me called. cookies present=${!!req.cookies} tokenProvided=${!!token}`);
|
this.logger.debug(`Me called (guarded). cookies=${JSON.stringify((req as any).cookies)} user=${JSON.stringify((req as any).user)}`);
|
||||||
const user = await this.authService.me(token);
|
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}`);
|
this.logger.debug(`Me returning user id=${user?.id}`);
|
||||||
return { success: true, data: user };
|
return { success: true, data: user };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { Module } from '@nestjs/common';
|
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 { AuthController } from './auth.controller';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
@@ -27,6 +32,7 @@ import { AuthorizationModule } from '../authorization/authorization.module';
|
|||||||
imports: [
|
imports: [
|
||||||
ConfigModule,
|
ConfigModule,
|
||||||
AuthorizationModule,
|
AuthorizationModule,
|
||||||
|
PassportModule.register({ defaultStrategy: 'jwt', session: false }),
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
useFactory: async (config: ConfigService) => ({
|
useFactory: async (config: ConfigService) => ({
|
||||||
@@ -59,6 +65,12 @@ import { AuthorizationModule } from '../authorization/authorization.module';
|
|||||||
EventBus,
|
EventBus,
|
||||||
AuditService,
|
AuditService,
|
||||||
RedisService,
|
RedisService,
|
||||||
|
|
||||||
|
// Strategies & Guards
|
||||||
|
JwtStrategy,
|
||||||
|
RefreshStrategy,
|
||||||
|
JwtGuard,
|
||||||
|
RefreshGuard,
|
||||||
],
|
],
|
||||||
exports: [AuthService, OidcService, RoleSyncService, AuthenticationService, EventBus],
|
exports: [AuthService, OidcService, RoleSyncService, AuthenticationService, EventBus],
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user