From 9084977108e75a897de47465d365fb70b9a07b9b Mon Sep 17 00:00:00 2001 From: Dipesh Kumar Sah Date: Fri, 30 Jan 2026 13:21:50 +0545 Subject: [PATCH 01/12] feat(service-auth): added new auth flow for internal service communication --- .../src/dtos/authDto/service-auth.dto.ts | 45 +++ libs/extensions/src/dtos/index.ts | 2 +- libs/user/src/index.ts | 2 + libs/user/src/lib/auths/auths.controller.ts | 96 ++++- libs/user/src/lib/auths/auths.module.ts | 11 +- libs/user/src/lib/auths/auths.service.ts | 355 ++++++++++++++---- .../src/lib/auths/guard/hybrid-jwt.guard.ts | 159 ++++++++ libs/user/src/lib/auths/guard/index.ts | 1 + libs/user/src/lib/auths/interfaces/index.ts | 3 + .../interfaces/service-auth.interface.ts | 34 ++ prisma/schema.prisma | 81 ++-- 11 files changed, 688 insertions(+), 101 deletions(-) create mode 100644 libs/extensions/src/dtos/authDto/service-auth.dto.ts create mode 100644 libs/user/src/lib/auths/guard/hybrid-jwt.guard.ts create mode 100644 libs/user/src/lib/auths/interfaces/index.ts create mode 100644 libs/user/src/lib/auths/interfaces/service-auth.interface.ts diff --git a/libs/extensions/src/dtos/authDto/service-auth.dto.ts b/libs/extensions/src/dtos/authDto/service-auth.dto.ts new file mode 100644 index 0000000..7804da6 --- /dev/null +++ b/libs/extensions/src/dtos/authDto/service-auth.dto.ts @@ -0,0 +1,45 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +/** + * DTO for OAuth2 Client Credentials Flow + * Used by external services (e.g., SMS Bridge) to authenticate + */ +export class ServiceAuthDto { + @ApiProperty({ + description: 'Service client ID', + example: 'clx1234567890abcdef', + }) + @IsString() + @IsNotEmpty() + clientId: string; + + @ApiProperty({ + description: 'Service client secret', + example: 'your-secret-key', + }) + @IsString() + @IsNotEmpty() + clientSecret: string; +} + +/** + * DTO for creating a new service client + */ +export class CreateServiceClientDto { + @ApiProperty({ + description: 'Name of the service', + example: 'SMS Bridge', + }) + @IsString() + @IsNotEmpty() + name: string; + + @ApiProperty({ + description: 'Description of the service', + example: 'External SMS gateway bridge service', + required: false, + }) + @IsString() + description?: string; +} diff --git a/libs/extensions/src/dtos/index.ts b/libs/extensions/src/dtos/index.ts index f560ba7..6f782ee 100644 --- a/libs/extensions/src/dtos/index.ts +++ b/libs/extensions/src/dtos/index.ts @@ -4,6 +4,7 @@ export * from './authDto/otp.dto'; export * from './authDto/otpLogin.dto'; export * from './authDto/password-login.dto'; export * from './authDto/reset-password.dto'; +export * from './authDto/service-auth.dto'; export * from './authDto/set-password.dto'; export * from './authDto/walletChallenge.dto'; export * from './authDto/walletLogin.dto'; @@ -22,4 +23,3 @@ export * from './userDto/create-user.dto'; export * from './userDto/update-user.dto'; export * from './userDto/users-get.dto'; export * from './userDto/users-list.dto'; - diff --git a/libs/user/src/index.ts b/libs/user/src/index.ts index 0d0ad59..83f3583 100755 --- a/libs/user/src/index.ts +++ b/libs/user/src/index.ts @@ -5,6 +5,8 @@ export * from './lib/ability/ability.subjects'; export * from './lib/auths/auths.module'; export * from './lib/auths/auths.service'; export * from './lib/auths/guard'; +export * from './lib/auths/interfaces'; +export * from './lib/auths/strategy'; export { ACTIONS, ERRORS, EVENTS, SUBJECTS } from './lib/constants'; export * from './lib/roles/roles.module'; export * from './lib/roles/roles.service'; diff --git a/libs/user/src/lib/auths/auths.controller.ts b/libs/user/src/lib/auths/auths.controller.ts index 9775ae8..989426f 100755 --- a/libs/user/src/lib/auths/auths.controller.ts +++ b/libs/user/src/lib/auths/auths.controller.ts @@ -1,23 +1,33 @@ import { Body, Controller, + Delete, Get, HttpCode, HttpStatus, + Param, + Patch, Post, Query, Request, UseGuards, } from '@nestjs/common'; -import { ApiQuery, ApiTags } from '@nestjs/swagger'; +import { + ApiBearerAuth, + ApiOperation, + ApiQuery, + ApiTags, +} from '@nestjs/swagger'; import { RequestDetails } from '@rumsan/extensions/decorators'; import { ChallengeDto, ChangePasswordDto, + CreateServiceClientDto, OtpDto, OtpLoginDto, PasswordLoginDto, ResetPasswordDto, + ServiceAuthDto, SetPasswordDto, WalletLoginDto, } from '@rumsan/extensions/dtos'; @@ -55,7 +65,10 @@ export class AuthsController { } @Post('challenge') - getChallenge(@Body() dto: ChallengeDto, @RequestDetails() rdetails: RequestType) { + getChallenge( + @Body() dto: ChallengeDto, + @RequestDetails() rdetails: RequestType, + ) { return this.authService.getChallengeForWallet(dto, rdetails); } @@ -110,7 +123,8 @@ export class AuthsController { name: 'service', enum: Service, required: true, - description: 'Service type to check password status for (EMAIL, PHONE, USERNAME)', + description: + 'Service type to check password status for (EMAIL, PHONE, USERNAME)', example: 'EMAIL', }) checkPasswordStatus( @@ -119,4 +133,80 @@ export class AuthsController { ) { return this.authService.hasPassword(user.id, service); } + + // ================== Service Authentication (OAuth2 Client Credentials) ================== + + @HttpCode(HttpStatus.OK) + @Post('service/token') + @ApiOperation({ + summary: 'Service Authentication', + description: + 'OAuth2 Client Credentials Flow - Exchange client_id/client_secret for a Service JWT', + }) + authenticateService(@Body() dto: ServiceAuthDto) { + return this.authService.authenticateService(dto); + } + + @UseGuards(JwtGuard) + @Post('service/clients') + @ApiBearerAuth() + @ApiOperation({ + summary: 'Create Service Client', + description: + 'Create a new service client. Returns clientId and secret (shown only once)', + }) + createServiceClient( + @CurrentUser() user: CurrentUserInterface, + @Body() dto: CreateServiceClientDto, + ) { + return this.authService.createServiceClient(dto, user.id); + } + + @UseGuards(JwtGuard) + @Get('service/clients') + @ApiBearerAuth() + @ApiOperation({ summary: 'List all service clients' }) + listServiceClients() { + return this.authService.listServiceClients(); + } + + @UseGuards(JwtGuard) + @Post('service/clients/:clientId/regenerate') + @ApiBearerAuth() + @ApiOperation({ + summary: 'Regenerate Service Client Secret', + description: + 'Generate a new secret for a service client. Old secret will be invalidated', + }) + regenerateServiceClientSecret(@Param('clientId') clientId: string) { + return this.authService.regenerateServiceClientSecret(clientId); + } + + @UseGuards(JwtGuard) + @Patch('service/clients/:clientId') + @ApiBearerAuth() + @ApiOperation({ summary: 'Update Service Client Permissions' }) + updateServiceClientPermissions( + @Param('clientId') clientId: string, + @Body() + permissions: { + canImpersonate?: boolean; + allowedRoles?: string[]; + rateLimit?: number; + isActive?: boolean; + }, + ) { + return this.authService.updateServiceClientPermissions( + clientId, + permissions, + ); + } + + @UseGuards(JwtGuard) + @Delete('service/clients/:clientId') + @ApiBearerAuth() + @ApiOperation({ summary: 'Delete Service Client' }) + deleteServiceClient(@Param('clientId') clientId: string) { + return this.authService.deleteServiceClient(clientId); + } } diff --git a/libs/user/src/lib/auths/auths.module.ts b/libs/user/src/lib/auths/auths.module.ts index bd31a21..5c72486 100755 --- a/libs/user/src/lib/auths/auths.module.ts +++ b/libs/user/src/lib/auths/auths.module.ts @@ -5,13 +5,20 @@ import { PassportModule } from '@nestjs/passport'; import { PrismaModule } from '@rumsan/prisma'; import { AuthsController } from './auths.controller'; import { AuthsService } from './auths.service'; +import { HybridJwtGuard } from './guard/hybrid-jwt.guard'; import { RateLimitService } from './services/rate-limit.service'; import { JwtStrategy, LocalStrategy } from './strategy'; @Module({ imports: [JwtModule.register({}), PrismaModule, PassportModule, ConfigModule], controllers: [AuthsController], - providers: [AuthsService, JwtStrategy, LocalStrategy, RateLimitService], - exports: [AuthsService], + providers: [ + AuthsService, + JwtStrategy, + LocalStrategy, + HybridJwtGuard, + RateLimitService, + ], + exports: [AuthsService, HybridJwtGuard], }) export class AuthsModule {} diff --git a/libs/user/src/lib/auths/auths.service.ts b/libs/user/src/lib/auths/auths.service.ts index 4544b19..edd3133 100755 --- a/libs/user/src/lib/auths/auths.service.ts +++ b/libs/user/src/lib/auths/auths.service.ts @@ -12,17 +12,20 @@ import { JwtService } from '@nestjs/jwt'; import { AuthSession, User } from '@prisma/client'; import { ChallengeDto, + CreateServiceClientDto, OtpDto, OtpLoginDto, ResetPasswordDto, + ServiceAuthDto, SetPasswordDto, - WalletLoginDto + WalletLoginDto, } from '@rumsan/extensions/dtos'; import { ERRORS as EXT_ERRORS } from '@rumsan/extensions/exceptions'; import { PrismaService } from '@rumsan/prisma'; import { CONSTANTS } from '@rumsan/sdk/constants'; import { Service } from '@rumsan/sdk/enums'; import { Request } from '@rumsan/sdk/types'; +import { randomBytes } from 'crypto'; import { hashMessage, recoverAddress } from 'viem'; import { ERRORS, EVENTS } from '../constants'; import { createChallenge, decryptChallenge } from '../utils/challenge.utils'; @@ -34,13 +37,14 @@ import { } from '../utils/password.utils'; import { getServiceTypeByAddress } from '../utils/service.utils'; import { TokenDataInterface } from './interfaces/auth.interface'; +import { ServiceTokenPayload } from './interfaces/service-auth.interface'; import { RateLimitService } from './services/rate-limit.service'; @Injectable() export class AuthsService { private readonly logger = new Logger(AuthsService.name); public request?: any; // Request context injected by LocalStrategy - + constructor( protected prisma: PrismaService, private jwt: JwtService, @@ -327,15 +331,18 @@ export class AuthsService { let user = null; let authId: number | undefined; let failReason: string | undefined; - + // Extract IP and userAgent from request context - const ip = this.request?.ip || this.request?.connection?.remoteAddress || 'unknown'; - const userAgent = this.request?.headers?.['user-agent'] || this.request?.get?.('user-agent'); - + const ip = + this.request?.ip || this.request?.connection?.remoteAddress || 'unknown'; + const userAgent = + this.request?.headers?.['user-agent'] || + this.request?.get?.('user-agent'); + try { // LAYER 1: Check IP rate limit (before any processing) await this.rateLimitService.checkIpRateLimit(ip); - + // Auto-detect service type if not provided if (!service) { try { @@ -344,7 +351,7 @@ export class AuthsService { service = Service.USERNAME; // Default to USERNAME if detection fails } } - + // Only EMAIL, PHONE, and USERNAME support password auth if ( service !== Service.EMAIL && @@ -354,19 +361,21 @@ export class AuthsService { this.logger.warn(`Password auth not supported for service: ${service}`); throw new UnauthorizedException('Invalid credentials'); } - + // Normalize identifier for USERNAME service (case-insensitive) - const lookupIdentifier = service === Service.USERNAME - ? identifier.toLowerCase() - : identifier; - + const lookupIdentifier = + service === Service.USERNAME ? identifier.toLowerCase() : identifier; + // LAYER 2: Check identifier rate limit - await this.rateLimitService.checkIdentifierRateLimit(lookupIdentifier, service); - + await this.rateLimitService.checkIdentifierRateLimit( + lookupIdentifier, + service, + ); + // LAYER 3: Find auth record const auth = await this.findAuthRecord(service, lookupIdentifier); authId = auth?.id; - + // LAYER 4: Check account lockout if (auth?.isLocked) { const wasUnlocked = await this.checkAndUnlockAccount(auth); @@ -375,15 +384,16 @@ export class AuthsService { throw ERRORS.ACCOUNT_LOCKED; } } - + // LAYER 5: Verify password (ALWAYS verify, even with dummy hash) // This ensures constant timing regardless of username existence const passwordHash = (auth as any)?.passwordHash; - const hashToCheck = passwordHash || + const hashToCheck = + passwordHash || '$2b$10$dummyHashToMaintainConstantTimingForSecurity1234567890'; - + const isPasswordValid = await verifyPassword(passwordInput, hashToCheck); - + // LAYER 6: Validate all conditions if (!auth || !passwordHash) { failReason = 'Invalid credentials - no auth record'; @@ -396,9 +406,11 @@ export class AuthsService { user = await this.getUserById(auth.userId); failReason = undefined; } - } catch (error) { - if (error instanceof HttpException && error.getStatus() === HttpStatus.TOO_MANY_REQUESTS) { + if ( + error instanceof HttpException && + error.getStatus() === HttpStatus.TOO_MANY_REQUESTS + ) { throw error; // Re-throw rate limit errors } if (error === ERRORS.ACCOUNT_LOCKED) { @@ -408,42 +420,45 @@ export class AuthsService { failReason = error instanceof Error ? error.message : 'Unknown error'; user = null; } - + // LAYER 7: Log attempt (for audit and rate limiting) - await this.rateLimitService.logAttempt({ - authId, - identifier: service === Service.USERNAME ? identifier.toLowerCase() : identifier, - service: service || Service.USERNAME, - ip, - userAgent, - success: !!user, - failReason - }).catch(() => { - // Silent fail - don't block login if logging fails - }); - + await this.rateLimitService + .logAttempt({ + authId, + identifier: + service === Service.USERNAME ? identifier.toLowerCase() : identifier, + service: service || Service.USERNAME, + ip, + userAgent, + success: !!user, + failReason, + }) + .catch(() => { + // Silent fail - don't block login if logging fails + }); + // LAYER 8: Handle failed attempt if (!user && authId) { await this.incrementFailedAttempts(authId); } - + // LAYER 9: Handle successful login if (user && authId) { await this.resetFailedAttempts(authId); } - + // LAYER 10: Ensure constant timing (100ms minimum) const elapsed = Date.now() - startTime; const minTime = this.config.get('AUTH_MIN_RESPONSE_TIME_MS', 100); if (elapsed < minTime) { - await new Promise(resolve => setTimeout(resolve, minTime - elapsed)); + await new Promise((resolve) => setTimeout(resolve, minTime - elapsed)); } - + // Always return same error message (no enumeration) if (!user) { throw new UnauthorizedException('Invalid credentials'); } - + return user; } @@ -456,15 +471,15 @@ export class AuthsService { return this.prisma.auth.findFirst({ where: { service: Service.USERNAME, - serviceIdLower: identifier.toLowerCase() - } as any + serviceIdLower: identifier.toLowerCase(), + } as any, }); } else { // Normal lookup for EMAIL/PHONE return this.prisma.auth.findUnique({ where: { - authIdentifier: { service, serviceId: identifier } - } + authIdentifier: { service, serviceId: identifier }, + }, }); } } @@ -477,43 +492,48 @@ export class AuthsService { if (!auth.isLocked || !auth.lockedOnAt) { return false; } - - const lockoutDuration = this.config.get('ACCOUNT_LOCKOUT_DURATION_MINUTES', 15); - const unlockTime = new Date(auth.lockedOnAt.getTime() + lockoutDuration * 60 * 1000); - + + const lockoutDuration = this.config.get( + 'ACCOUNT_LOCKOUT_DURATION_MINUTES', + 15, + ); + const unlockTime = new Date( + auth.lockedOnAt.getTime() + lockoutDuration * 60 * 1000, + ); + if (new Date() >= unlockTime) { // Time has passed, unlock account await this.prisma.auth.update({ where: { id: auth.id }, - data: { isLocked: false, falseAttempts: 0, lockedOnAt: null } + data: { isLocked: false, falseAttempts: 0, lockedOnAt: null }, }); return true; } - + return false; } /** * Increment failed attempts and lock if threshold reached - */ + */ private async incrementFailedAttempts(authId: number): Promise { const threshold = this.config.get('ACCOUNT_LOCKOUT_THRESHOLD', 5); - + // Update and get the new falseAttempts count await this.prisma.auth.update({ where: { id: authId }, data: { falseAttempts: { increment: 1 }, - lastFailedAt: new Date() - } as any + lastFailedAt: new Date(), + } as any, }); - + // Fetch the updated auth to check falseAttempts const auth = await this.prisma.auth.findUnique({ where: { id: authId }, - select: { falseAttempts: true } + select: { falseAttempts: true }, }); - + if (auth && auth.falseAttempts >= threshold) { await this.prisma.auth.update({ where: { id: authId }, @@ -521,8 +541,8 @@ export class AuthsService { isLocked: true, lockedOnAt: new Date(), lockCount: { increment: 1 }, - falseAttempts: 0 - } as any + falseAttempts: 0, + } as any, }); } } @@ -536,8 +556,8 @@ export class AuthsService { data: { falseAttempts: 0, isLocked: false, - lastFailedAt: null - } as any + lastFailedAt: null, + } as any, }); } @@ -559,7 +579,9 @@ export class AuthsService { await this.updateLastLogin(auth.id); // Generate clientId if not provided - const clientId = requestInfo.clientId || `client_${Date.now()}_${Math.random().toString(36).substring(7)}`; + const clientId = + requestInfo.clientId || + `client_${Date.now()}_${Math.random().toString(36).substring(7)}`; // Create auth session const session = await this.prisma.authSession.create({ @@ -585,8 +607,7 @@ export class AuthsService { this.config.get('PASSWORD_REQUIRE_UPPERCASE') ?? true, requireLowercase: this.config.get('PASSWORD_REQUIRE_LOWERCASE') ?? true, - requireDigit: - this.config.get('PASSWORD_REQUIRE_DIGIT') ?? true, + requireDigit: this.config.get('PASSWORD_REQUIRE_DIGIT') ?? true, requireSpecial: this.config.get('PASSWORD_REQUIRE_SPECIAL') ?? true, }); @@ -618,7 +639,9 @@ export class AuthsService { // Check if password already exists if (auth.passwordHash) { - throw new ForbiddenException('Password already set. Use change password instead.'); + throw new ForbiddenException( + 'Password already set. Use change password instead.', + ); } // Hash and store password @@ -671,8 +694,7 @@ export class AuthsService { this.config.get('PASSWORD_REQUIRE_UPPERCASE') ?? true, requireLowercase: this.config.get('PASSWORD_REQUIRE_LOWERCASE') ?? true, - requireDigit: - this.config.get('PASSWORD_REQUIRE_DIGIT') ?? true, + requireDigit: this.config.get('PASSWORD_REQUIRE_DIGIT') ?? true, requireSpecial: this.config.get('PASSWORD_REQUIRE_SPECIAL') ?? true, }); @@ -735,8 +757,7 @@ export class AuthsService { this.config.get('PASSWORD_REQUIRE_UPPERCASE') ?? true, requireLowercase: this.config.get('PASSWORD_REQUIRE_LOWERCASE') ?? true, - requireDigit: - this.config.get('PASSWORD_REQUIRE_DIGIT') ?? true, + requireDigit: this.config.get('PASSWORD_REQUIRE_DIGIT') ?? true, requireSpecial: this.config.get('PASSWORD_REQUIRE_SPECIAL') ?? true, }); @@ -788,4 +809,200 @@ export class AuthsService { service, }; } + + // ================== Service Authentication (OAuth2 Client Credentials) ================== + + /** + * Authenticate a service using client_id and client_secret (OAuth2 Client Credentials Flow) + * Returns a Service JWT with role: "INTERNAL_SERVICE" + */ + async authenticateService( + dto: ServiceAuthDto, + ): Promise<{ accessToken: string }> { + const { clientId, clientSecret } = dto; + + const serviceClient = await this.prisma.serviceClient.findUnique({ + where: { clientId }, + }); + + if (!serviceClient || serviceClient.deletedAt) { + this.logger.warn(`Service auth failed: client not found - ${clientId}`); + throw new UnauthorizedException('Invalid client credentials'); + } + + if (!serviceClient.isActive) { + this.logger.warn(`Service auth failed: client inactive - ${clientId}`); + throw new UnauthorizedException('Service client is inactive'); + } + + // Verify client secret + const isSecretValid = await verifyPassword( + clientSecret, + serviceClient.clientSecret, + ); + if (!isSecretValid) { + this.logger.warn(`Service auth failed: invalid secret - ${clientId}`); + throw new UnauthorizedException('Invalid client credentials'); + } + + // Update last used timestamp + await this.prisma.serviceClient.update({ + where: { id: serviceClient.id }, + data: { lastUsedAt: new Date() }, + }); + + // Generate service JWT + const payload: ServiceTokenPayload = { + clientId: serviceClient.clientId, + serviceName: serviceClient.name, + role: 'INTERNAL_SERVICE', + }; + + const expiryTime = this.config.get('SERVICE_JWT_EXPIRATION_TIME') || '1h'; + + const token = await this.jwt.signAsync(payload, { + expiresIn: expiryTime, + secret: getSecret(), + }); + + this.logger.log(`Service authenticated: ${serviceClient.name}`); + + return { accessToken: token }; + } + + /** + * Create a new service client + * Returns the clientId and plaintext clientSecret (only shown once) + */ + async createServiceClient( + dto: CreateServiceClientDto, + createdBy?: number, + ): Promise<{ clientId: string; clientSecret: string; name: string }> { + // Generate a secure random secret + const plainSecret = randomBytes(32).toString('hex'); + const hashedSecret = await hashPassword(plainSecret); + + const serviceClient = await this.prisma.serviceClient.create({ + data: { + name: dto.name, + description: dto.description, + clientSecret: hashedSecret, + createdBy, + }, + }); + + this.logger.log( + `Service client created: ${dto.name} (${serviceClient.clientId})`, + ); + + return { + clientId: serviceClient.clientId, + clientSecret: plainSecret, // Only returned once during creation + name: serviceClient.name, + }; + } + + /** + * Regenerate client secret for a service client + * Returns the new plaintext clientSecret (only shown once) + */ + async regenerateServiceClientSecret( + clientId: string, + ): Promise<{ clientId: string; clientSecret: string }> { + const serviceClient = await this.prisma.serviceClient.findUnique({ + where: { clientId }, + }); + + if (!serviceClient || serviceClient.deletedAt) { + throw new ForbiddenException('Service client not found'); + } + + const plainSecret = randomBytes(32).toString('hex'); + const hashedSecret = await hashPassword(plainSecret); + + await this.prisma.serviceClient.update({ + where: { id: serviceClient.id }, + data: { clientSecret: hashedSecret }, + }); + + this.logger.log(`Service client secret regenerated: ${serviceClient.name}`); + + return { + clientId, + clientSecret: plainSecret, + }; + } + + /** + * Update service client permissions + */ + async updateServiceClientPermissions( + clientId: string, + permissions: { + canImpersonate?: boolean; + allowedRoles?: string[]; + rateLimit?: number; + isActive?: boolean; + }, + ) { + const serviceClient = await this.prisma.serviceClient.findUnique({ + where: { clientId }, + }); + + if (!serviceClient || serviceClient.deletedAt) { + throw new ForbiddenException('Service client not found'); + } + + await this.prisma.serviceClient.update({ + where: { id: serviceClient.id }, + data: permissions, + }); + + this.logger.log(`Service client updated: ${serviceClient.name}`); + + return { success: true }; + } + + /** + * List all service clients (without secrets) + */ + async listServiceClients() { + return this.prisma.serviceClient.findMany({ + where: { deletedAt: null }, + select: { + id: true, + clientId: true, + name: true, + description: true, + isActive: true, + canImpersonate: true, + allowedRoles: true, + rateLimit: true, + lastUsedAt: true, + createdAt: true, + }, + }); + } + + /** + * Delete (soft) a service client + */ + async deleteServiceClient(clientId: string) { + const serviceClient = await this.prisma.serviceClient.findUnique({ + where: { clientId }, + }); + + if (!serviceClient || serviceClient.deletedAt) { + throw new ForbiddenException('Service client not found'); + } + + await this.prisma.serviceClient.update({ + where: { id: serviceClient.id }, + data: { deletedAt: new Date(), isActive: false }, + }); + + this.logger.log(`Service client deleted: ${serviceClient.name}`); + + return { success: true }; + } } diff --git a/libs/user/src/lib/auths/guard/hybrid-jwt.guard.ts b/libs/user/src/lib/auths/guard/hybrid-jwt.guard.ts new file mode 100644 index 0000000..7ed5f4e --- /dev/null +++ b/libs/user/src/lib/auths/guard/hybrid-jwt.guard.ts @@ -0,0 +1,159 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { PrismaService } from '@rumsan/prisma'; +import { getSecret } from '../../utils/config.utils'; + +/** + * Hybrid JWT Guard - Dynamically switches between User and Service strategies + * + * This guard allows the same route to be accessed by: + * 1. Regular users (with normal JWT) + * 2. External services (with Service JWT + optional impersonation) + * + * How it works: + * - Decodes the JWT token from Authorization header + * - If token has `role: "INTERNAL_SERVICE"`, treats it as a service request + * - For service requests, handles X-Impersonate-Id header to load user context + * - For regular users, just validates the token and attaches user to request + */ +@Injectable() +export class HybridJwtGuard implements CanActivate { + constructor( + private jwtService: JwtService, + private prisma: PrismaService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const token = this.extractToken(request); + + if (!token) { + throw new UnauthorizedException('No token provided'); + } + + try { + // Decode and verify token + const payload = await this.jwtService.verifyAsync(token, { + secret: getSecret(), + }); + + // Check if this is a service token + if (payload.role === 'INTERNAL_SERVICE') { + return this.handleServiceAuth(request, payload); + } + + // Regular user token - attach to request + request.user = payload; + return true; + } catch (error) { + throw new UnauthorizedException('Invalid token'); + } + } + + /** + * Handle service authentication with optional impersonation + */ + private async handleServiceAuth( + request: any, + payload: any, + ): Promise { + const serviceClient = await this.prisma.serviceClient.findFirst({ + where: { + clientId: payload.clientId, + isActive: true, + deletedAt: null, + }, + }); + + if (!serviceClient) { + throw new UnauthorizedException('Service client not found or inactive'); + } + + const impersonateId = request.headers['x-impersonate-id']; + + if (impersonateId) { + if (!serviceClient.canImpersonate) { + throw new UnauthorizedException( + 'This service is not allowed to impersonate users', + ); + } + + const user = await this.loadUserById(impersonateId); + if (!user) { + throw new UnauthorizedException('Impersonated user not found'); + } + + const userRoles = await this.prisma.userRole.findMany({ + where: { userId: user.id }, + include: { Role: true }, + }); + + const userRoleNames = userRoles.map((ur: any) => ur.Role.name); + // Check allowed roles restriction + if (serviceClient.allowedRoles && serviceClient.allowedRoles.length > 0) { + const canImpersonate = userRoleNames.some((role: string) => + serviceClient.allowedRoles.includes(role), + ); + + if (!canImpersonate) { + throw new UnauthorizedException( + 'Service not allowed to impersonate users with these roles', + ); + } + } + + request.user = { + id: user.id, + userId: user.id, + uuid: user.uuid, + name: user.name, + email: user.email, + phone: user.phone, + wallet: user.wallet, + roles: userRoleNames, + isServiceRequest: true, + serviceClientId: payload.clientId, + serviceName: payload.serviceName, + impersonatedBy: payload.serviceName, + }; + } else { + request.user = { + isServiceRequest: true, + role: 'INTERNAL_SERVICE', + clientId: payload.clientId, + serviceName: payload.serviceName, + permissions: [], + }; + } + + return true; + } + + /** + * Extract Bearer token from Authorization header + */ + private extractToken(request: any): string | null { + const authHeader = request.headers?.authorization; + if (!authHeader) return null; + + const [type, token] = authHeader.split(' '); + return type === 'Bearer' ? token : null; + } + + /** + * Load user by ID or UUID + */ + private async loadUserById(id: string) { + return this.prisma.user.findFirst({ + where: { + OR: [{ id: isNaN(Number(id)) ? undefined : Number(id) }, { uuid: id }], + deletedAt: null, + }, + }); + } +} diff --git a/libs/user/src/lib/auths/guard/index.ts b/libs/user/src/lib/auths/guard/index.ts index 293a487..2da1630 100755 --- a/libs/user/src/lib/auths/guard/index.ts +++ b/libs/user/src/lib/auths/guard/index.ts @@ -1,2 +1,3 @@ +export * from './hybrid-jwt.guard'; export * from './jwt.guard'; export * from './local.guard'; diff --git a/libs/user/src/lib/auths/interfaces/index.ts b/libs/user/src/lib/auths/interfaces/index.ts new file mode 100644 index 0000000..deb1aa4 --- /dev/null +++ b/libs/user/src/lib/auths/interfaces/index.ts @@ -0,0 +1,3 @@ +export * from './auth.interface'; +export * from './current-user.interface'; +export * from './service-auth.interface'; diff --git a/libs/user/src/lib/auths/interfaces/service-auth.interface.ts b/libs/user/src/lib/auths/interfaces/service-auth.interface.ts new file mode 100644 index 0000000..77132aa --- /dev/null +++ b/libs/user/src/lib/auths/interfaces/service-auth.interface.ts @@ -0,0 +1,34 @@ +import { CurrentUserInterface } from './current-user.interface'; + +/** + * Payload structure for Service JWT tokens + * Used by external services authenticating via OAuth2 Client Credentials + */ +export interface ServiceTokenPayload { + clientId: string; + serviceName: string; + role: 'INTERNAL_SERVICE'; + iat?: number; + exp?: number; +} + +/** + * Extended user interface when request comes from a service + * Includes metadata about the service making the request + */ +export interface ServiceUserContext extends Partial { + isServiceRequest: true; + serviceClientId: string; + serviceName: string; + impersonatedBy?: string; +} + +/** + * Context when service makes request without impersonation + */ +export interface ServiceOnlyContext { + isServiceRequest: true; + role: 'INTERNAL_SERVICE'; + clientId: string; + serviceName: string; +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fe88b73..0f01a15 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -18,7 +18,7 @@ model User { uuid String @unique @default(uuid()) name String username String? @unique - usernameLower String? @unique // NEW: For case-insensitive username lookups + usernameLower String? @unique // NEW: For case-insensitive username lookups gender Gender @default(UNKNOWN) email String? phone String? @@ -102,22 +102,22 @@ model UserRole { } model Auth { - id Int @id @default(autoincrement()) - userId Int - service Service - serviceId String - serviceIdLower String? // NEW: For case-insensitive USERNAME lookups - details Json? @db.JsonB() - challenge String? - passwordHash String? - falseAttempts Int @default(0) - isLocked Boolean @default(false) - lockedOnAt DateTime? - lockCount Int @default(0) // NEW: Track how many times locked - lastFailedAt DateTime? // NEW: Track last failed attempt timestamp - lastLoginAt DateTime? - - User User @relation(fields: [userId], references: [id]) + id Int @id @default(autoincrement()) + userId Int + service Service + serviceId String + serviceIdLower String? // NEW: For case-insensitive USERNAME lookups + details Json? @db.JsonB() + challenge String? + passwordHash String? + falseAttempts Int @default(0) + isLocked Boolean @default(false) + lockedOnAt DateTime? + lockCount Int @default(0) // NEW: Track how many times locked + lastFailedAt DateTime? // NEW: Track last failed attempt timestamp + lastLoginAt DateTime? + + User User @relation(fields: [userId], references: [id]) AuthLog AuthSession[] LoginAttempt LoginAttempt[] // NEW: Relationship to login attempts @@ -126,7 +126,7 @@ model Auth { deletedAt DateTime? @@unique([service, serviceId], name: "authIdentifier") - @@index([service, serviceIdLower]) // NEW: Index for performance + @@index([service, serviceIdLower]) // NEW: Index for performance @@map("tbl_auth") } @@ -163,20 +163,20 @@ enum Service { // NEW TABLE: Track all login attempts for rate limiting model LoginAttempt { id Int @id @default(autoincrement()) - authId Int? // Nullable: might not exist for invalid usernames - identifier String // Username/email/phone attempted + authId Int? // Nullable: might not exist for invalid usernames + identifier String // Username/email/phone attempted service Service ip String userAgent String? success Boolean - failReason String? // Why it failed (locked, invalid, etc.) + failReason String? // Why it failed (locked, invalid, etc.) createdAt DateTime @default(now()) - + Auth Auth? @relation(fields: [authId], references: [id]) - - @@index([ip, createdAt]) // For IP-based rate limiting - @@index([identifier, createdAt]) // For username-based rate limiting - @@index([authId, createdAt]) // For per-account tracking + + @@index([ip, createdAt]) // For IP-based rate limiting + @@index([identifier, createdAt]) // For username-based rate limiting + @@index([authId, createdAt]) // For per-account tracking @@map("tbl_login_attempts") } @@ -253,5 +253,34 @@ model Application { @@map("tbl_applications") } + // ++++++++++++++++++ END: @rumsan/extensions/apps ++++++++++++++++++++++++++++++++ +// ++++++++++++++++++ START: Service Authentication (OAuth2 Client Credentials) +++ +// Stores external service clients that can authenticate via client_id/client_secret +model ServiceClient { + id Int @id @default(autoincrement()) + clientId String @unique @default(cuid()) + clientSecret String // Hashed secret + name String // e.g., "SMS Bridge", "Payment Gateway" + description String? + isActive Boolean @default(true) + + // Permissions: what this service can do + canImpersonate Boolean @default(false) // Can use X-Impersonate-Id header + allowedRoles String[] // Roles this service can impersonate (empty = all) + + // Rate limiting + rateLimit Int @default(1000) // Requests per minute + + // Audit + lastUsedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + createdBy Int? + + @@map("tbl_service_clients") +} + +// ++++++++++++++++++ END: Service Authentication ++++++++++++++++++++++++++++++++ From db9510a6a6455b91f3324cc7f784423f790dc7dc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 07:59:11 +0000 Subject: [PATCH 02/12] Initial plan From 7e5962cea403a469d98629ae40046b3d4c3e4e06 Mon Sep 17 00:00:00 2001 From: Dipesh Kumar Sah Date: Fri, 30 Jan 2026 13:45:45 +0545 Subject: [PATCH 03/12] Update and refactor find user logic Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- libs/user/src/lib/auths/guard/hybrid-jwt.guard.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/libs/user/src/lib/auths/guard/hybrid-jwt.guard.ts b/libs/user/src/lib/auths/guard/hybrid-jwt.guard.ts index 7ed5f4e..5b014f1 100644 --- a/libs/user/src/lib/auths/guard/hybrid-jwt.guard.ts +++ b/libs/user/src/lib/auths/guard/hybrid-jwt.guard.ts @@ -149,9 +149,18 @@ export class HybridJwtGuard implements CanActivate { * Load user by ID or UUID */ private async loadUserById(id: string) { + const orConditions: { id?: number; uuid?: string }[] = []; + + const numericId = Number(id); + if (!Number.isNaN(numericId)) { + orConditions.push({ id: numericId }); + } + + orConditions.push({ uuid: id }); + return this.prisma.user.findFirst({ where: { - OR: [{ id: isNaN(Number(id)) ? undefined : Number(id) }, { uuid: id }], + OR: orConditions, deletedAt: null, }, }); From c65bb24ebbf332328ba127d2b1163d3d756d6549 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 08:03:15 +0000 Subject: [PATCH 04/12] feat: add audit logging for service impersonation events Co-authored-by: dipesh-rumsan <203831631+dipesh-rumsan@users.noreply.github.com> --- .../src/lib/auths/guard/hybrid-jwt.guard.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/libs/user/src/lib/auths/guard/hybrid-jwt.guard.ts b/libs/user/src/lib/auths/guard/hybrid-jwt.guard.ts index 7ed5f4e..0b62c34 100644 --- a/libs/user/src/lib/auths/guard/hybrid-jwt.guard.ts +++ b/libs/user/src/lib/auths/guard/hybrid-jwt.guard.ts @@ -2,6 +2,7 @@ import { CanActivate, ExecutionContext, Injectable, + Logger, UnauthorizedException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; @@ -23,6 +24,8 @@ import { getSecret } from '../../utils/config.utils'; */ @Injectable() export class HybridJwtGuard implements CanActivate { + private readonly logger = new Logger(HybridJwtGuard.name); + constructor( private jwtService: JwtService, private prisma: PrismaService, @@ -78,6 +81,12 @@ export class HybridJwtGuard implements CanActivate { if (impersonateId) { if (!serviceClient.canImpersonate) { + // Log failed impersonation attempt + this.logger.warn( + `Service impersonation denied: service="${payload.serviceName}" ` + + `clientId="${payload.clientId}" impersonateId="${impersonateId}" ` + + `reason="Service not allowed to impersonate" ip="${request.ip || 'unknown'}"`, + ); throw new UnauthorizedException( 'This service is not allowed to impersonate users', ); @@ -85,6 +94,12 @@ export class HybridJwtGuard implements CanActivate { const user = await this.loadUserById(impersonateId); if (!user) { + // Log failed impersonation attempt + this.logger.warn( + `Service impersonation denied: service="${payload.serviceName}" ` + + `clientId="${payload.clientId}" impersonateId="${impersonateId}" ` + + `reason="User not found" ip="${request.ip || 'unknown'}"`, + ); throw new UnauthorizedException('Impersonated user not found'); } @@ -101,12 +116,28 @@ export class HybridJwtGuard implements CanActivate { ); if (!canImpersonate) { + // Log failed impersonation attempt + this.logger.warn( + `Service impersonation denied: service="${payload.serviceName}" ` + + `clientId="${payload.clientId}" impersonateId="${impersonateId}" ` + + `userId="${user.id}" userRoles="${userRoleNames.join(',')}" ` + + `reason="User roles not allowed" ip="${request.ip || 'unknown'}"`, + ); throw new UnauthorizedException( 'Service not allowed to impersonate users with these roles', ); } } + // Log successful impersonation + this.logger.log( + `Service impersonation granted: service="${payload.serviceName}" ` + + `clientId="${payload.clientId}" impersonatedUser="${user.uuid}" ` + + `userId="${user.id}" userName="${user.name}" userEmail="${user.email || 'none'}" ` + + `userRoles="${userRoleNames.join(',')}" ip="${request.ip || 'unknown'}" ` + + `userAgent="${request.headers['user-agent'] || 'unknown'}"`, + ); + request.user = { id: user.id, userId: user.id, From d88b862297205ea53c7ae7f306a525ea25772ec2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 08:04:44 +0000 Subject: [PATCH 05/12] refactor: reduce PII in audit logs and add logger documentation Co-authored-by: dipesh-rumsan <203831631+dipesh-rumsan@users.noreply.github.com> --- libs/user/src/lib/auths/guard/hybrid-jwt.guard.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/libs/user/src/lib/auths/guard/hybrid-jwt.guard.ts b/libs/user/src/lib/auths/guard/hybrid-jwt.guard.ts index 0b62c34..42f12b1 100644 --- a/libs/user/src/lib/auths/guard/hybrid-jwt.guard.ts +++ b/libs/user/src/lib/auths/guard/hybrid-jwt.guard.ts @@ -24,6 +24,10 @@ import { getSecret } from '../../utils/config.utils'; */ @Injectable() export class HybridJwtGuard implements CanActivate { + /** + * Logger for audit logging of service impersonation events. + * Tracks both successful and failed impersonation attempts for security monitoring. + */ private readonly logger = new Logger(HybridJwtGuard.name); constructor( @@ -133,7 +137,6 @@ export class HybridJwtGuard implements CanActivate { this.logger.log( `Service impersonation granted: service="${payload.serviceName}" ` + `clientId="${payload.clientId}" impersonatedUser="${user.uuid}" ` + - `userId="${user.id}" userName="${user.name}" userEmail="${user.email || 'none'}" ` + `userRoles="${userRoleNames.join(',')}" ip="${request.ip || 'unknown'}" ` + `userAgent="${request.headers['user-agent'] || 'unknown'}"`, ); From cadd312acfe9dfc0dd36d26fb5b6dd8a2ddf45a6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 08:06:21 +0000 Subject: [PATCH 06/12] refactor: simplify log format and remove sensitive ID exposure Co-authored-by: dipesh-rumsan <203831631+dipesh-rumsan@users.noreply.github.com> --- .../src/lib/auths/guard/hybrid-jwt.guard.ts | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/libs/user/src/lib/auths/guard/hybrid-jwt.guard.ts b/libs/user/src/lib/auths/guard/hybrid-jwt.guard.ts index 42f12b1..c6a3922 100644 --- a/libs/user/src/lib/auths/guard/hybrid-jwt.guard.ts +++ b/libs/user/src/lib/auths/guard/hybrid-jwt.guard.ts @@ -87,9 +87,7 @@ export class HybridJwtGuard implements CanActivate { if (!serviceClient.canImpersonate) { // Log failed impersonation attempt this.logger.warn( - `Service impersonation denied: service="${payload.serviceName}" ` + - `clientId="${payload.clientId}" impersonateId="${impersonateId}" ` + - `reason="Service not allowed to impersonate" ip="${request.ip || 'unknown'}"`, + `Service impersonation denied - ${payload.serviceName}: service not allowed to impersonate`, ); throw new UnauthorizedException( 'This service is not allowed to impersonate users', @@ -100,9 +98,7 @@ export class HybridJwtGuard implements CanActivate { if (!user) { // Log failed impersonation attempt this.logger.warn( - `Service impersonation denied: service="${payload.serviceName}" ` + - `clientId="${payload.clientId}" impersonateId="${impersonateId}" ` + - `reason="User not found" ip="${request.ip || 'unknown'}"`, + `Service impersonation denied - ${payload.serviceName}: user not found`, ); throw new UnauthorizedException('Impersonated user not found'); } @@ -122,10 +118,7 @@ export class HybridJwtGuard implements CanActivate { if (!canImpersonate) { // Log failed impersonation attempt this.logger.warn( - `Service impersonation denied: service="${payload.serviceName}" ` + - `clientId="${payload.clientId}" impersonateId="${impersonateId}" ` + - `userId="${user.id}" userRoles="${userRoleNames.join(',')}" ` + - `reason="User roles not allowed" ip="${request.ip || 'unknown'}"`, + `Service impersonation denied - ${payload.serviceName}: user ${user.uuid} roles not allowed`, ); throw new UnauthorizedException( 'Service not allowed to impersonate users with these roles', @@ -135,10 +128,7 @@ export class HybridJwtGuard implements CanActivate { // Log successful impersonation this.logger.log( - `Service impersonation granted: service="${payload.serviceName}" ` + - `clientId="${payload.clientId}" impersonatedUser="${user.uuid}" ` + - `userRoles="${userRoleNames.join(',')}" ip="${request.ip || 'unknown'}" ` + - `userAgent="${request.headers['user-agent'] || 'unknown'}"`, + `Service impersonation granted - ${payload.serviceName}: user ${user.uuid} with roles ${userRoleNames.join(',')}`, ); request.user = { From 3ca44280ac68101b702337de5ec4662b15711fba Mon Sep 17 00:00:00 2001 From: Dipesh Kumar Sah Date: Wed, 4 Feb 2026 16:07:54 +0545 Subject: [PATCH 07/12] feat(docs): add usefull commands in docs --- docs/readme.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/readme.md b/docs/readme.md index d830142..af85b5d 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -2,3 +2,27 @@ This is how you will write the library ![FLow](diagrams/development.excalidraw.svg) + +## Quick Commands + +```bash +# Install dependencies +pnpm install + +# Build everything +pnpm nx run-many -t build + +# Run sample app (dev) +pnpm nx serve sample + +# Build specific project +pnpm nx build + +# Test & Lint +pnpm nx test sample +pnpm nx lint sample + +# run the production build +pnpm nx run sample:build:production +node dist/apps/sample/main.js +``` From 48a1bbc2162a9f1205fdd22b3cbd96b295eb9369 Mon Sep 17 00:00:00 2001 From: Dipesh Kumar Sah Date: Wed, 4 Feb 2026 16:34:45 +0545 Subject: [PATCH 08/12] feat(migration): add migration file for sample project --- .../migration.sql | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 prisma/migrations/20260204101200_add_new_service_client_table/migration.sql diff --git a/prisma/migrations/20260204101200_add_new_service_client_table/migration.sql b/prisma/migrations/20260204101200_add_new_service_client_table/migration.sql new file mode 100644 index 0000000..249be36 --- /dev/null +++ b/prisma/migrations/20260204101200_add_new_service_client_table/migration.sql @@ -0,0 +1,22 @@ +-- CreateTable +CREATE TABLE "tbl_service_clients" ( + "id" SERIAL NOT NULL, + "clientId" TEXT NOT NULL, + "clientSecret" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "canImpersonate" BOOLEAN NOT NULL DEFAULT false, + "allowedRoles" TEXT[], + "rateLimit" INTEGER NOT NULL DEFAULT 1000, + "lastUsedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + "createdBy" INTEGER, + + CONSTRAINT "tbl_service_clients_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "tbl_service_clients_clientId_key" ON "tbl_service_clients"("clientId"); From 860589bf37c3f11fc2d6d172b8831588b32caea9 Mon Sep 17 00:00:00 2001 From: Dipesh Kumar Sah Date: Wed, 4 Feb 2026 17:07:52 +0545 Subject: [PATCH 09/12] fix(guard): enhance guard to support user permission) --- .../src/lib/auths/guard/hybrid-jwt.guard.ts | 134 ++++++++++-------- 1 file changed, 71 insertions(+), 63 deletions(-) diff --git a/libs/user/src/lib/auths/guard/hybrid-jwt.guard.ts b/libs/user/src/lib/auths/guard/hybrid-jwt.guard.ts index 0d9903d..50af6da 100644 --- a/libs/user/src/lib/auths/guard/hybrid-jwt.guard.ts +++ b/libs/user/src/lib/auths/guard/hybrid-jwt.guard.ts @@ -83,69 +83,7 @@ export class HybridJwtGuard implements CanActivate { const impersonateId = request.headers['x-impersonate-id']; - if (impersonateId) { - if (!serviceClient.canImpersonate) { - // Log failed impersonation attempt - this.logger.warn( - `Service impersonation denied - ${payload.serviceName}: service not allowed to impersonate`, - ); - throw new UnauthorizedException( - 'This service is not allowed to impersonate users', - ); - } - - const user = await this.loadUserById(impersonateId); - if (!user) { - // Log failed impersonation attempt - this.logger.warn( - `Service impersonation denied - ${payload.serviceName}: user not found`, - ); - throw new UnauthorizedException('Impersonated user not found'); - } - - const userRoles = await this.prisma.userRole.findMany({ - where: { userId: user.id }, - include: { Role: true }, - }); - - const userRoleNames = userRoles.map((ur: any) => ur.Role.name); - // Check allowed roles restriction - if (serviceClient.allowedRoles && serviceClient.allowedRoles.length > 0) { - const canImpersonate = userRoleNames.some((role: string) => - serviceClient.allowedRoles.includes(role), - ); - - if (!canImpersonate) { - // Log failed impersonation attempt - this.logger.warn( - `Service impersonation denied - ${payload.serviceName}: user ${user.uuid} roles not allowed`, - ); - throw new UnauthorizedException( - 'Service not allowed to impersonate users with these roles', - ); - } - } - - // Log successful impersonation - this.logger.log( - `Service impersonation granted - ${payload.serviceName}: user ${user.uuid} with roles ${userRoleNames.join(',')}`, - ); - - request.user = { - id: user.id, - userId: user.id, - uuid: user.uuid, - name: user.name, - email: user.email, - phone: user.phone, - wallet: user.wallet, - roles: userRoleNames, - isServiceRequest: true, - serviceClientId: payload.clientId, - serviceName: payload.serviceName, - impersonatedBy: payload.serviceName, - }; - } else { + if (!impersonateId) { request.user = { isServiceRequest: true, role: 'INTERNAL_SERVICE', @@ -153,8 +91,78 @@ export class HybridJwtGuard implements CanActivate { serviceName: payload.serviceName, permissions: [], }; + return true; } + if (!serviceClient.canImpersonate) { + this.logger.warn( + `Service impersonation denied - ${payload.serviceName}: service not allowed to impersonate`, + ); + throw new UnauthorizedException( + 'This service is not allowed to impersonate users', + ); + } + + const user = await this.loadUserById(impersonateId); + if (!user) { + this.logger.warn( + `Service impersonation denied - ${payload.serviceName}: user not found`, + ); + throw new UnauthorizedException('Impersonated user not found'); + } + + const userRoles = await this.prisma.userRole.findMany({ + where: { userId: user.id }, + include: { Role: true }, + }); + + const userPermissions = await this.prisma.permission.findMany({ + where: { + roleId: { + in: userRoles.map((ur) => ur.roleId), + }, + }, + }); + + const userRoleNames = userRoles.map((ur: any) => ur.Role.name); + // Check allowed roles restriction + if (serviceClient.allowedRoles && serviceClient.allowedRoles.length > 0) { + const canImpersonate = userRoleNames.some((role: string) => + serviceClient.allowedRoles.includes(role), + ); + + if (!canImpersonate) { + // Log failed impersonation attempt + this.logger.warn( + `Service impersonation denied - ${payload.serviceName}: user ${user.uuid} roles not allowed`, + ); + throw new UnauthorizedException( + 'Service not allowed to impersonate users with these roles', + ); + } + } + + // Log successful impersonation + this.logger.log( + `Service impersonation granted - ${payload.serviceName}: user ${user.uuid} with roles ${userRoleNames.join(',')}`, + ); + + request.user = { + id: user.id, + userId: user.id, + uuid: user.uuid, + name: user.name, + email: user.email, + phone: user.phone, + wallet: user.wallet, + roles: userRoleNames, + permissions: userPermissions, + isServiceRequest: true, + serviceClientId: payload.clientId, + serviceName: payload.serviceName, + impersonatedBy: payload.serviceName, + }; + return true; } From 8b30afd9dc5e137f96ee00b36f9a9b078b1800b9 Mon Sep 17 00:00:00 2001 From: Dipesh Kumar Sah Date: Tue, 10 Feb 2026 16:00:10 +0545 Subject: [PATCH 10/12] fix(dependency): fix dependency error of HybridJwtGuard --- libs/user/src/lib/auths/auths.module.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/user/src/lib/auths/auths.module.ts b/libs/user/src/lib/auths/auths.module.ts index 5c72486..ead3d78 100755 --- a/libs/user/src/lib/auths/auths.module.ts +++ b/libs/user/src/lib/auths/auths.module.ts @@ -19,6 +19,6 @@ import { JwtStrategy, LocalStrategy } from './strategy'; HybridJwtGuard, RateLimitService, ], - exports: [AuthsService, HybridJwtGuard], + exports: [AuthsService, HybridJwtGuard, JwtModule, PrismaModule], }) export class AuthsModule {} From 0cb6bc9142f7acb590cab8d45985927505ea4434 Mon Sep 17 00:00:00 2001 From: Dipesh Kumar Sah Date: Tue, 10 Feb 2026 16:07:31 +0545 Subject: [PATCH 11/12] chore(export): fix export --- libs/extensions/src/dtos/authDto/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/extensions/src/dtos/authDto/index.ts b/libs/extensions/src/dtos/authDto/index.ts index cd564e4..230b807 100644 --- a/libs/extensions/src/dtos/authDto/index.ts +++ b/libs/extensions/src/dtos/authDto/index.ts @@ -4,6 +4,6 @@ export * from './otp.dto'; export * from './otpLogin.dto'; export * from './password-login.dto'; export * from './reset-password.dto'; +export * from './service-auth.dto'; export * from './set-password.dto'; export * from './walletLogin.dto'; - From 5025cca4d13a533d08859e0c5a99f7b88edaea03 Mon Sep 17 00:00:00 2001 From: manjik-rumsan Date: Wed, 25 Mar 2026 08:51:17 +0545 Subject: [PATCH 12/12] pacakge publish --- libs/user/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/user/package.json b/libs/user/package.json index 3a67f68..33defce 100644 --- a/libs/user/package.json +++ b/libs/user/package.json @@ -1,6 +1,6 @@ { "name": "@rumsan/user", - "version": "3.0.170-rc.3", + "version": "3.0.170-rc.5", "description": "Rumsan user management library", "author": "rumsan.com", "license": "AGPL-3.0",