From 160040a4b7152caec842e3f2213dd9f1f270a516 Mon Sep 17 00:00:00 2001 From: mahyargdz Date: Mon, 21 Apr 2025 10:41:43 +0330 Subject: [PATCH] chore: add sso service and logic --- .env.example | 9 +- src/configs/typeorm.config.ts | 2 +- .../DTO/requests/create-sso-client.dto.ts | 35 +++ .../DTO/requests/sso-token-request.dto.ts | 9 + .../DTO/requests/sso-token-validate.dto.ts | 14 + .../DTO/requests/update-sso-client.dto.ts | 35 +++ src/modules/auth/auth.controller.ts | 36 ++- src/modules/auth/auth.module.ts | 20 +- .../auth/controllers/sso-client.controller.ts | 85 ++++++ .../auth/entities/sso-client.entity.ts | 27 ++ src/modules/auth/interfaces/IToken-payload.ts | 1 + .../auth/providers/sso-client.service.ts | 96 +++++++ src/modules/auth/providers/sso.service.ts | 244 ++++++++++++++++++ .../repositories/sso-client.repository.ts | 12 + .../repositories/blog-comments.repository.ts | 2 +- src/modules/users/enums/permission.enum.ts | 1 + 16 files changed, 619 insertions(+), 9 deletions(-) create mode 100644 src/modules/auth/DTO/requests/create-sso-client.dto.ts create mode 100644 src/modules/auth/DTO/requests/sso-token-request.dto.ts create mode 100644 src/modules/auth/DTO/requests/sso-token-validate.dto.ts create mode 100644 src/modules/auth/DTO/requests/update-sso-client.dto.ts create mode 100644 src/modules/auth/controllers/sso-client.controller.ts create mode 100644 src/modules/auth/entities/sso-client.entity.ts create mode 100644 src/modules/auth/providers/sso-client.service.ts create mode 100644 src/modules/auth/providers/sso.service.ts create mode 100644 src/modules/auth/repositories/sso-client.repository.ts diff --git a/.env.example b/.env.example index 1a04c3b..b73c0b5 100755 --- a/.env.example +++ b/.env.example @@ -35,8 +35,13 @@ THROTTLE_LIMIT=10 JWT_SECRET_KEY=secret -ACCESS_TOKEN_EXPIRE=1 #==> in hour -REFRESH_TOKEN_EXPIRE=3 #===> in days +# JWT configuration +JWT_ISSUER=danak-dsc-api +ACCESS_TOKEN_EXPIRE=60 +REFRESH_TOKEN_EXPIRE=30 + +# SSO configuration +ALLOWED_SSO_CLIENTS=danak-web-client,danak-mobile-app,danak-dashboard BUCKET_NAME=dsc BUCKET_REGION=default diff --git a/src/configs/typeorm.config.ts b/src/configs/typeorm.config.ts index 9391ab2..8d8378f 100755 --- a/src/configs/typeorm.config.ts +++ b/src/configs/typeorm.config.ts @@ -13,7 +13,7 @@ export function databaseConfigs(): TypeOrmModuleAsyncOptions { username: configService.getOrThrow("DB_USER"), password: configService.getOrThrow("DB_PASS"), autoLoadEntities: true, - synchronize: configService.getOrThrow("NODE_ENV") == "production" ? false : false, + synchronize: configService.getOrThrow("NODE_ENV") == "production" ? false : true, logging: configService.getOrThrow("NODE_ENV") == "production" ? false : true, migrationsTableName: "typeorm_migrations", migrationsRun: false, diff --git a/src/modules/auth/DTO/requests/create-sso-client.dto.ts b/src/modules/auth/DTO/requests/create-sso-client.dto.ts new file mode 100644 index 0000000..5defb3d --- /dev/null +++ b/src/modules/auth/DTO/requests/create-sso-client.dto.ts @@ -0,0 +1,35 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsArray, IsBoolean, IsNotEmpty, IsOptional, IsString } from "class-validator"; + +export class CreateSSOClientDTO { + @ApiProperty({ description: "Client ID (unique identifier)", example: "dashboard-client" }) + @IsString({ message: "Client ID must be a string" }) + @IsNotEmpty({ message: "Client ID is required" }) + clientId: string; + + @ApiProperty({ description: "Client name", example: "Danak Dashboard" }) + @IsString({ message: "Name must be a string" }) + @IsNotEmpty({ message: "Name is required" }) + name: string; + + @ApiProperty({ description: "Client description", example: "Dashboard application for administrators", required: false }) + @IsString({ message: "Description must be a string" }) + @IsOptional() + description?: string; + + @ApiProperty({ description: "Whether the client requires a secret", example: false, required: false }) + @IsBoolean({ message: "Requires secret must be a boolean" }) + @IsOptional() + requiresSecret?: boolean; + + @ApiProperty({ + description: "List of allowed redirect URLs for this client", + example: ["https://dashboard.danak.co/auth/callback"], + type: [String], + required: false, + }) + @IsArray({ message: "Allowed redirect URLs must be an array" }) + @IsString({ each: true, message: "Each redirect URL must be a string" }) + @IsOptional() + allowedRedirectUrls?: string[]; +} diff --git a/src/modules/auth/DTO/requests/sso-token-request.dto.ts b/src/modules/auth/DTO/requests/sso-token-request.dto.ts new file mode 100644 index 0000000..a6e0706 --- /dev/null +++ b/src/modules/auth/DTO/requests/sso-token-request.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotEmpty, IsString } from "class-validator"; + +export class SSOTokenRequestDTO { + @ApiProperty({ description: "Client application identifier", example: "danak-web-client" }) + @IsNotEmpty({ message: "Client ID is required" }) + @IsString({ message: "Client ID must be a string" }) + clientId: string; +} diff --git a/src/modules/auth/DTO/requests/sso-token-validate.dto.ts b/src/modules/auth/DTO/requests/sso-token-validate.dto.ts new file mode 100644 index 0000000..a45c277 --- /dev/null +++ b/src/modules/auth/DTO/requests/sso-token-validate.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotEmpty, IsString } from "class-validator"; + +export class SSOTokenValidateDTO { + @ApiProperty({ description: "SSO token to validate", example: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." }) + @IsNotEmpty({ message: "Token is required" }) + @IsString({ message: "Token must be a string" }) + token: string; + + @ApiProperty({ description: "Client application identifier", example: "danak-web-client" }) + @IsNotEmpty({ message: "Client ID is required" }) + @IsString({ message: "Client ID must be a string" }) + clientId: string; +} diff --git a/src/modules/auth/DTO/requests/update-sso-client.dto.ts b/src/modules/auth/DTO/requests/update-sso-client.dto.ts new file mode 100644 index 0000000..5032e7f --- /dev/null +++ b/src/modules/auth/DTO/requests/update-sso-client.dto.ts @@ -0,0 +1,35 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsArray, IsBoolean, IsOptional, IsString } from "class-validator"; + +export class UpdateSSOClientDTO { + @ApiProperty({ description: "Client name", example: "Danak Dashboard", required: false }) + @IsString({ message: "Name must be a string" }) + @IsOptional() + name?: string; + + @ApiProperty({ description: "Client description", example: "Dashboard application for administrators", required: false }) + @IsString({ message: "Description must be a string" }) + @IsOptional() + description?: string; + + @ApiProperty({ description: "Whether the client is active", example: true, required: false }) + @IsBoolean({ message: "Is active must be a boolean" }) + @IsOptional() + isActive?: boolean; + + @ApiProperty({ description: "Whether the client requires a secret", example: false, required: false }) + @IsBoolean({ message: "Requires secret must be a boolean" }) + @IsOptional() + requiresSecret?: boolean; + + @ApiProperty({ + description: "List of allowed redirect URLs for this client", + example: ["https://dashboard.danak.co/auth/callback"], + type: [String], + required: false, + }) + @IsArray({ message: "Allowed redirect URLs must be an array" }) + @IsString({ each: true, message: "Each redirect URL must be a string" }) + @IsOptional() + allowedRedirectUrls?: string[]; +} diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index e52621d..890a7ea 100755 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -7,18 +7,24 @@ import { CompleteRegistrationDto } from "./DTO/complete-register.dto"; import { CheckUserExistDto, LoginPasswordDTO } from "./DTO/loginPassword.dto"; import { RefreshTokenDto } from "./DTO/refresh-token.dto"; import { RequestOtpDto } from "./DTO/request-otp.dto"; +import { SSOTokenRequestDTO } from "./DTO/requests/sso-token-request.dto"; import { VerifyOtpDto } from "./DTO/verify-otp.dto"; import { AuthService } from "./providers/auth.service"; +import { SSOService } from "./providers/sso.service"; import { AUTH_THROTTLE_LIMIT, AUTH_THROTTLE_TTL, AUTH__REFRESH_THROTTLE_LIMIT, AUTH__REFRESH_THROTTLE_TTL } from "../../common/constants"; +import { SSOTokenValidateDTO } from "./DTO/requests/sso-token-validate.dto"; import { AuthGuards } from "../../common/decorators/auth-guard.decorator"; import { UserDec } from "../../common/decorators/user.decorator"; - +import { User } from "../users/entities/user.entity"; @ApiTags("Auth") @Controller("auth") @Throttle({ default: { limit: AUTH_THROTTLE_LIMIT, ttl: AUTH_THROTTLE_TTL } }) @UseGuards(ThrottlerGuard) export class AuthController { - constructor(private authService: AuthService) {} + constructor( + private readonly authService: AuthService, + private readonly ssoService: SSOService, + ) {} @ApiOperation({ summary: "Initiate registration" }) @HttpCode(HttpStatus.OK) @@ -110,4 +116,30 @@ export class AuthController { // async resetPassword(@Body() resetPasswordDto: ResetPasswordDto) { // return this.authService.resetPassword(resetPasswordDto); // } + + @ApiOperation({ summary: "Generate SSO token for client application" }) + @HttpCode(HttpStatus.OK) + @Post("/sso/token") + async generateSSOToken(@UserDec() user: User, @Body() body: SSOTokenRequestDTO) { + const { clientId } = body; + const ssoToken = await this.ssoService.generateSSOToken(user, clientId); + return { + statusCode: HttpStatus.OK, + data: ssoToken, + message: "SSO token generated successfully", + }; + } + + @ApiOperation({ summary: "Validate SSO token" }) + @HttpCode(HttpStatus.OK) + @Post("/sso/validate") + async validateSSOToken(@Body() body: SSOTokenValidateDTO) { + const { token, clientId } = body; + const payload = await this.ssoService.validateSSOToken(token, clientId); + return { + statusCode: HttpStatus.OK, + data: payload, + message: "SSO token validated successfully", + }; + } } diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 2e0d8eb..7b9b175 100755 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -1,21 +1,35 @@ import { Module } from "@nestjs/common"; import { JwtModule } from "@nestjs/jwt"; import { PassportModule } from "@nestjs/passport"; +import { TypeOrmModule } from "@nestjs/typeorm"; import { AuthController } from "./auth.controller"; +// import { SSOClientController } from "./controllers/sso-client.controller"; +import { SSOClient } from "./entities/sso-client.entity"; import { jwtConfig } from "../../configs/jwt.config"; import { UsersModule } from "../users/users.module"; import { UtilsModule } from "../utils/utils.module"; import { AuthService } from "./providers/auth.service"; +import { SSOClientService } from "./providers/sso-client.service"; +import { SSOService } from "./providers/sso.service"; import { TokensService } from "./providers/tokens.service"; +import { SSOClientRepository } from "./repositories/sso-client.repository"; import { JwtStrategy } from "./strategies/jwt.strategy"; import { NotificationModule } from "../notifications/notifications.module"; import { ReferralsModule } from "../referrals/referrals.module"; @Module({ - imports: [UtilsModule, UsersModule, PassportModule, JwtModule.registerAsync(jwtConfig()), NotificationModule, ReferralsModule], + imports: [ + UtilsModule, + UsersModule, + PassportModule, + JwtModule.registerAsync(jwtConfig()), + NotificationModule, + ReferralsModule, + TypeOrmModule.forFeature([SSOClient]), + ], controllers: [AuthController], - providers: [AuthService, TokensService, JwtStrategy], - exports: [AuthService], + providers: [AuthService, TokensService, SSOService, SSOClientService, SSOClientRepository, JwtStrategy], + exports: [AuthService, SSOService, SSOClientService], }) export class AuthModule {} diff --git a/src/modules/auth/controllers/sso-client.controller.ts b/src/modules/auth/controllers/sso-client.controller.ts new file mode 100644 index 0000000..35969f4 --- /dev/null +++ b/src/modules/auth/controllers/sso-client.controller.ts @@ -0,0 +1,85 @@ +// import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from "@nestjs/common"; +// import { ApiOperation, ApiTags } from "@nestjs/swagger"; + +// // import { AuthGuards } from "../../../common/decorators/auth-guard.decorator"; +// import { CreateSSOClientDTO } from "../DTO/requests/create-sso-client.dto"; +// import { UpdateSSOClientDTO } from "../DTO/requests/update-sso-client.dto"; +// import { SSOClientService } from "../providers/sso-client.service"; + +// @ApiTags("Admin - SSO Clients") +// @Controller("admin/sso-clients") +// // @AuthGuards(["MANAGE_SSO_CLIENTS"]) +// export class SSOClientController { +// constructor(private readonly ssoClientService: SSOClientService) {} + +// @ApiOperation({ summary: "Get all SSO clients" }) +// @HttpCode(HttpStatus.OK) +// @Get() +// async findAll() { +// const clients = await this.ssoClientService.findAll(); +// return { +// statusCode: HttpStatus.OK, +// data: clients, +// message: "SSO clients retrieved successfully", +// }; +// } + +// @ApiOperation({ summary: "Get SSO client by ID" }) +// @HttpCode(HttpStatus.OK) +// @Get(":clientId") +// async findOne(@Param("clientId") clientId: string) { +// const client = await this.ssoClientService.findOne(clientId); +// return { +// statusCode: HttpStatus.OK, +// data: client, +// message: "SSO client retrieved successfully", +// }; +// } + +// @ApiOperation({ summary: "Create new SSO client" }) +// @HttpCode(HttpStatus.CREATED) +// @Post() +// async create(@Body() createDto: CreateSSOClientDTO) { +// const client = await this.ssoClientService.create(createDto); +// return { +// statusCode: HttpStatus.CREATED, +// data: client, +// message: "SSO client created successfully", +// }; +// } + +// @ApiOperation({ summary: "Update SSO client" }) +// @HttpCode(HttpStatus.OK) +// @Put(":clientId") +// async update(@Param("clientId") clientId: string, @Body() updateDto: UpdateSSOClientDTO) { +// const client = await this.ssoClientService.update(clientId, updateDto); +// return { +// statusCode: HttpStatus.OK, +// data: client, +// message: "SSO client updated successfully", +// }; +// } + +// @ApiOperation({ summary: "Delete SSO client" }) +// @HttpCode(HttpStatus.NO_CONTENT) +// @Delete(":clientId") +// async remove(@Param("clientId") clientId: string) { +// await this.ssoClientService.remove(clientId); +// return { +// statusCode: HttpStatus.NO_CONTENT, +// message: "SSO client deleted successfully", +// }; +// } + +// @ApiOperation({ summary: "Regenerate client secret" }) +// @HttpCode(HttpStatus.OK) +// @Post(":clientId/regenerate-secret") +// async regenerateSecret(@Param("clientId") clientId: string) { +// const result = await this.ssoClientService.regenerateSecret(clientId); +// return { +// statusCode: HttpStatus.OK, +// data: result, +// message: "Client secret regenerated successfully", +// }; +// } +// } diff --git a/src/modules/auth/entities/sso-client.entity.ts b/src/modules/auth/entities/sso-client.entity.ts new file mode 100644 index 0000000..8a837c8 --- /dev/null +++ b/src/modules/auth/entities/sso-client.entity.ts @@ -0,0 +1,27 @@ +import { Column, Entity } from "typeorm"; + +import { BaseEntity } from "../../../common/entities/base.entity"; + +@Entity() +export class SSOClient extends BaseEntity { + @Column({ unique: true }) + clientId: string; + + @Column() + name: string; + + @Column({ type: "varchar", nullable: true }) + description: string | null; + + @Column({ default: true }) + isActive: boolean; + + @Column("simple-array", { nullable: true }) + allowedRedirectUrls: string[]; + + @Column({ type: "text", nullable: true }) + clientSecret: string | null; + + @Column({ default: false }) + requiresSecret: boolean; +} diff --git a/src/modules/auth/interfaces/IToken-payload.ts b/src/modules/auth/interfaces/IToken-payload.ts index 222c2b5..5b86c07 100755 --- a/src/modules/auth/interfaces/IToken-payload.ts +++ b/src/modules/auth/interfaces/IToken-payload.ts @@ -5,4 +5,5 @@ export interface ITokenPayload { id: string; isAdmin: boolean; permissions: PermissionEnum[]; + audience?: string; // Client application identifier for SSO } diff --git a/src/modules/auth/providers/sso-client.service.ts b/src/modules/auth/providers/sso-client.service.ts new file mode 100644 index 0000000..81d96b2 --- /dev/null +++ b/src/modules/auth/providers/sso-client.service.ts @@ -0,0 +1,96 @@ +import { randomBytes } from "crypto"; + +import { Injectable, NotFoundException } from "@nestjs/common"; + +import { SSOClient } from "../entities/sso-client.entity"; +import { SSOClientRepository } from "../repositories/sso-client.repository"; + +@Injectable() +export class SSOClientService { + // private readonly logger = new Logger(SSOClientService.name); + + constructor(private readonly ssoClientRepository: SSOClientRepository) {} + + async findAll(): Promise { + return this.ssoClientRepository.find(); + } + + async findOne(clientId: string): Promise { + const client = await this.ssoClientRepository.findOne({ where: { clientId } }); + if (!client) { + throw new NotFoundException(`SSO client with ID ${clientId} not found`); + } + return client; + } + + async findActive(clientId: string): Promise { + return this.ssoClientRepository.findOne({ + where: { clientId, isActive: true }, + }); + } + + async create(createDto: { + clientId: string; + name: string; + description?: string; + requiresSecret?: boolean; + allowedRedirectUrls?: string[]; + }): Promise { + const client = this.ssoClientRepository.create({ + ...createDto, + clientSecret: createDto.requiresSecret ? this.generateClientSecret() : null, + }); + + return this.ssoClientRepository.save(client); + } + + async update( + clientId: string, + updateDto: { + name?: string; + description?: string; + isActive?: boolean; + allowedRedirectUrls?: string[]; + requiresSecret?: boolean; + }, + ): Promise { + const client = await this.findOne(clientId); + + // If we're changing from no secret to requiring a secret, generate one + if (updateDto.requiresSecret === true && !client.requiresSecret) { + client.clientSecret = this.generateClientSecret(); + } + + // If we're changing from requiring a secret to not requiring one, clear it + if (updateDto.requiresSecret === false && client.requiresSecret) { + client.clientSecret = null; + } + + // Update the client with the DTO values + Object.assign(client, updateDto); + + return this.ssoClientRepository.save(client); + } + + async regenerateSecret(clientId: string): Promise<{ clientSecret: string }> { + const client = await this.findOne(clientId); + + if (!client.requiresSecret) { + throw new Error("This client is not configured to use a client secret"); + } + + client.clientSecret = this.generateClientSecret(); + await this.ssoClientRepository.save(client); + + return { clientSecret: client.clientSecret }; + } + + async remove(clientId: string): Promise { + const client = await this.findOne(clientId); + await this.ssoClientRepository.remove(client); + } + + private generateClientSecret(): string { + return randomBytes(32).toString("hex"); + } +} diff --git a/src/modules/auth/providers/sso.service.ts b/src/modules/auth/providers/sso.service.ts new file mode 100644 index 0000000..906160a --- /dev/null +++ b/src/modules/auth/providers/sso.service.ts @@ -0,0 +1,244 @@ +import { Injectable, Logger, UnauthorizedException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { JwtService } from "@nestjs/jwt"; +import { DataSource } from "typeorm"; + +import { SSOClientService } from "./sso-client.service"; +import { TokensService } from "./tokens.service"; +import { AuthMessage } from "../../../common/enums/message.enum"; +import { User } from "../../users/entities/user.entity"; +import { ITokenPayload } from "../interfaces/IToken-payload"; + +@Injectable() +export class SSOService { + private readonly logger = new Logger(SSOService.name); + // Keep this temporarily for backward compatibility during migration + private readonly allowedClients: string[] = []; + + constructor( + private readonly configService: ConfigService, + private readonly jwtService: JwtService, + private readonly tokensService: TokensService, + private readonly dataSource: DataSource, + private readonly ssoClientService: SSOClientService, + ) { + // Load allowed client applications from config - kept for backward compatibility + const allowedClientsString = this.configService.get("ALLOWED_SSO_CLIENTS", ""); + if (allowedClientsString) { + this.allowedClients = allowedClientsString.split(",").map((client) => client.trim()); + } + } + + /** + * Generate an SSO token for a specific client application + */ + async generateSSOToken(user: User, clientId: string) { + // First check if client exists in database + const client = await this.ssoClientService.findActive(clientId); + + // Fall back to environment variable list if client not found in DB (for backward compatibility) + if (!client && !this.allowedClients.includes(clientId)) { + this.logger.warn(`Unauthorized SSO request for client: ${clientId}`); + throw new UnauthorizedException(AuthMessage.UNAUTHORIZED_ACCESS); + } + + // Generate token with client-specific audience + const payload: ITokenPayload = { + id: user.id, + isAdmin: user.roles.some((r) => r.isAdmin), + permissions: user.roles.flatMap((r) => (r?.permissions?.length ? r.permissions.map((p) => p.name) : [])), + audience: clientId, + }; + + const accessExpire = this.configService.getOrThrow("ACCESS_TOKEN_EXPIRE"); + const ssoToken = this.jwtService.sign(payload, { + expiresIn: `${accessExpire}m`, + audience: clientId, + }); + + return { + token: ssoToken, + expire: new Date(Date.now() + accessExpire * 60 * 1000).valueOf(), + }; + } + + /** + * Validate an SSO token from another service + */ + async validateSSOToken(token: string, clientId: string) { + // Check if client exists in database + const client = await this.ssoClientService.findActive(clientId); + + // Fall back to environment variable list if client not found in DB (for backward compatibility) + if (!client && !this.allowedClients.includes(clientId)) { + this.logger.warn(`Unauthorized SSO validation for client: ${clientId}`); + throw new UnauthorizedException(AuthMessage.UNAUTHORIZED_ACCESS); + } + + try { + const payload = this.jwtService.verify(token, { + audience: clientId, + }); + + return payload; + } catch (error: unknown) { + this.logger.error(`Invalid SSO token: ${error instanceof Error ? error.message : "Unknown error"}`); + throw new UnauthorizedException(AuthMessage.TOKEN_INVALID); + } + } + + /** + * Generate SSO tokens with refresh capability + * Uses TokensService to create and store refresh tokens + */ + async generateTokensWithRefresh(user: User, clientId: string) { + // Check if client exists in database + const client = await this.ssoClientService.findActive(clientId); + + // Fall back to environment variable list if client not found in DB (for backward compatibility) + if (!client && !this.allowedClients.includes(clientId)) { + this.logger.warn(`Unauthorized SSO request for client: ${clientId}`); + throw new UnauthorizedException(AuthMessage.UNAUTHORIZED_ACCESS); + } + + // Create token payload with client-specific audience + const payload: ITokenPayload = { + id: user.id, + isAdmin: user.roles.some((r) => r.isAdmin), + permissions: user.roles.flatMap((r) => (r?.permissions?.length ? r.permissions.map((p) => p.name) : [])), + audience: clientId, + }; + + // Use a transaction for token generation + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + // Generate access and refresh tokens with TokensService + const accessExpire = this.configService.getOrThrow("ACCESS_TOKEN_EXPIRE"); + const accessToken = this.jwtService.sign(payload, { + expiresIn: `${accessExpire}m`, + audience: clientId, + }); + + // Generate refresh token using TokensService + const refreshToken = await this.tokensService.storeRefreshToken( + user.id, + this.jwtService.sign( + { id: user.id, audience: clientId }, + { expiresIn: `${this.configService.getOrThrow("REFRESH_TOKEN_EXPIRE")}d` }, + ), + queryRunner, + ); + + await queryRunner.commitTransaction(); + + return { + accessToken: { + token: accessToken, + expire: new Date(Date.now() + accessExpire * 60 * 1000).valueOf(), + }, + refreshToken, + }; + } catch (error) { + this.logger.error(`Failed to generate SSO tokens: ${error instanceof Error ? error.message : "Unknown error"}`); + await queryRunner.rollbackTransaction(); + throw error; + } finally { + await queryRunner.release(); + } + } + + /** + * Refresh an SSO token using a refresh token + */ + async refreshSSOToken(refreshToken: string, clientId: string) { + // Check if client exists in database + const client = await this.ssoClientService.findActive(clientId); + + // Fall back to environment variable list if client not found in DB (for backward compatibility) + if (!client && !this.allowedClients.includes(clientId)) { + this.logger.warn(`Unauthorized SSO refresh for client: ${clientId}`); + throw new UnauthorizedException(AuthMessage.UNAUTHORIZED_ACCESS); + } + + try { + // Verify the token has the correct audience + const decoded = this.jwtService.verify(refreshToken, { ignoreExpiration: false }); + if (decoded.audience !== clientId) { + throw new UnauthorizedException(AuthMessage.UNAUTHORIZED_ACCESS); + } + + // Use TokensService to refresh the token + return this.tokensService.refreshToken(refreshToken); + } catch (error) { + this.logger.error(`Failed to refresh SSO token: ${error instanceof Error ? error.message : "Unknown error"}`); + throw new UnauthorizedException(AuthMessage.TOKEN_INVALID); + } + } + + /** + * Revoke all SSO refresh tokens for a user + */ + async revokeSSOTokens(userId: string) { + try { + await this.tokensService.invalidateRefreshToken(userId); + this.logger.log(`Successfully revoked all SSO tokens for user ${userId}`); + return true; + } catch (error) { + this.logger.error(`Failed to revoke SSO tokens: ${error instanceof Error ? error.message : "Unknown error"}`); + throw error; + } + } + + /** + * Revoke a specific SSO token for a client + */ + async revokeSSOTokenForClient(userId: string, clientId: string) { + // Check if client exists in database + const client = await this.ssoClientService.findActive(clientId); + + // Fall back to environment variable list if client not found in DB (for backward compatibility) + if (!client && !this.allowedClients.includes(clientId)) { + this.logger.warn(`Unauthorized SSO token revocation for client: ${clientId}`); + throw new UnauthorizedException(AuthMessage.UNAUTHORIZED_ACCESS); + } + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + // Find and delete refresh tokens for this user with this client in the audience + const refreshTokenRepo = queryRunner.manager.getRepository("refresh_token"); + const tokens = await refreshTokenRepo.find({ + where: { user: { id: userId } }, + relations: { user: true }, + }); + + // For each token, check if it has the correct audience + for (const token of tokens) { + try { + const decoded = this.jwtService.decode(token.token) as { audience?: string }; + if (decoded?.audience === clientId) { + await queryRunner.manager.delete(refreshTokenRepo.target, { id: token.id }); + } + } catch (_e) { + // Skip tokens that can't be decoded + continue; + } + } + + await queryRunner.commitTransaction(); + this.logger.log(`Successfully revoked SSO tokens for user ${userId} and client ${clientId}`); + return true; + } catch (error) { + this.logger.error(`Failed to revoke client SSO tokens: ${error instanceof Error ? error.message : "Unknown error"}`); + await queryRunner.rollbackTransaction(); + throw error; + } finally { + await queryRunner.release(); + } + } +} diff --git a/src/modules/auth/repositories/sso-client.repository.ts b/src/modules/auth/repositories/sso-client.repository.ts new file mode 100644 index 0000000..5df199d --- /dev/null +++ b/src/modules/auth/repositories/sso-client.repository.ts @@ -0,0 +1,12 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { SSOClient } from "../entities/sso-client.entity"; + +@Injectable() +export class SSOClientRepository extends Repository { + constructor(@InjectRepository(SSOClient) repository: Repository) { + super(repository.target, repository.manager, repository.queryRunner); + } +} diff --git a/src/modules/blogs/repositories/blog-comments.repository.ts b/src/modules/blogs/repositories/blog-comments.repository.ts index 8aee641..95a0b3a 100755 --- a/src/modules/blogs/repositories/blog-comments.repository.ts +++ b/src/modules/blogs/repositories/blog-comments.repository.ts @@ -48,7 +48,7 @@ export class BlogCommentsRepository extends Repository { content: true, createdAt: true, status: true, - user: { id: true, firstName: true, lastName: true }, + user: { id: true, firstName: true, lastName: true, profilePic: true }, }, }); } diff --git a/src/modules/users/enums/permission.enum.ts b/src/modules/users/enums/permission.enum.ts index d61095a..1ab953a 100755 --- a/src/modules/users/enums/permission.enum.ts +++ b/src/modules/users/enums/permission.enum.ts @@ -18,4 +18,5 @@ export enum PermissionEnum { SETTINGS = "settings", BANK_ACCOUNTS = "bank_accounts", PAYMENTS = "payments", + MANAGE_SSO_CLIENTS = "manage_sso_clients", }