chore: add sso service and logic
This commit is contained in:
+7
-2
@@ -35,8 +35,13 @@ THROTTLE_LIMIT=10
|
|||||||
|
|
||||||
JWT_SECRET_KEY=secret
|
JWT_SECRET_KEY=secret
|
||||||
|
|
||||||
ACCESS_TOKEN_EXPIRE=1 #==> in hour
|
# JWT configuration
|
||||||
REFRESH_TOKEN_EXPIRE=3 #===> in days
|
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_NAME=dsc
|
||||||
BUCKET_REGION=default
|
BUCKET_REGION=default
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function databaseConfigs(): TypeOrmModuleAsyncOptions {
|
|||||||
username: configService.getOrThrow<string>("DB_USER"),
|
username: configService.getOrThrow<string>("DB_USER"),
|
||||||
password: configService.getOrThrow<string>("DB_PASS"),
|
password: configService.getOrThrow<string>("DB_PASS"),
|
||||||
autoLoadEntities: true,
|
autoLoadEntities: true,
|
||||||
synchronize: configService.getOrThrow<string>("NODE_ENV") == "production" ? false : false,
|
synchronize: configService.getOrThrow<string>("NODE_ENV") == "production" ? false : true,
|
||||||
logging: configService.getOrThrow<string>("NODE_ENV") == "production" ? false : true,
|
logging: configService.getOrThrow<string>("NODE_ENV") == "production" ? false : true,
|
||||||
migrationsTableName: "typeorm_migrations",
|
migrationsTableName: "typeorm_migrations",
|
||||||
migrationsRun: false,
|
migrationsRun: false,
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -7,18 +7,24 @@ import { CompleteRegistrationDto } from "./DTO/complete-register.dto";
|
|||||||
import { CheckUserExistDto, LoginPasswordDTO } from "./DTO/loginPassword.dto";
|
import { CheckUserExistDto, LoginPasswordDTO } from "./DTO/loginPassword.dto";
|
||||||
import { RefreshTokenDto } from "./DTO/refresh-token.dto";
|
import { RefreshTokenDto } from "./DTO/refresh-token.dto";
|
||||||
import { RequestOtpDto } from "./DTO/request-otp.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 { VerifyOtpDto } from "./DTO/verify-otp.dto";
|
||||||
import { AuthService } from "./providers/auth.service";
|
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 { 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 { AuthGuards } from "../../common/decorators/auth-guard.decorator";
|
||||||
import { UserDec } from "../../common/decorators/user.decorator";
|
import { UserDec } from "../../common/decorators/user.decorator";
|
||||||
|
import { User } from "../users/entities/user.entity";
|
||||||
@ApiTags("Auth")
|
@ApiTags("Auth")
|
||||||
@Controller("auth")
|
@Controller("auth")
|
||||||
@Throttle({ default: { limit: AUTH_THROTTLE_LIMIT, ttl: AUTH_THROTTLE_TTL } })
|
@Throttle({ default: { limit: AUTH_THROTTLE_LIMIT, ttl: AUTH_THROTTLE_TTL } })
|
||||||
@UseGuards(ThrottlerGuard)
|
@UseGuards(ThrottlerGuard)
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private authService: AuthService) {}
|
constructor(
|
||||||
|
private readonly authService: AuthService,
|
||||||
|
private readonly ssoService: SSOService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@ApiOperation({ summary: "Initiate registration" })
|
@ApiOperation({ summary: "Initiate registration" })
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@@ -110,4 +116,30 @@ export class AuthController {
|
|||||||
// async resetPassword(@Body() resetPasswordDto: ResetPasswordDto) {
|
// async resetPassword(@Body() resetPasswordDto: ResetPasswordDto) {
|
||||||
// return this.authService.resetPassword(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",
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,35 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { JwtModule } from "@nestjs/jwt";
|
import { JwtModule } from "@nestjs/jwt";
|
||||||
import { PassportModule } from "@nestjs/passport";
|
import { PassportModule } from "@nestjs/passport";
|
||||||
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
|
|
||||||
import { AuthController } from "./auth.controller";
|
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 { jwtConfig } from "../../configs/jwt.config";
|
||||||
import { UsersModule } from "../users/users.module";
|
import { UsersModule } from "../users/users.module";
|
||||||
import { UtilsModule } from "../utils/utils.module";
|
import { UtilsModule } from "../utils/utils.module";
|
||||||
import { AuthService } from "./providers/auth.service";
|
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 { TokensService } from "./providers/tokens.service";
|
||||||
|
import { SSOClientRepository } from "./repositories/sso-client.repository";
|
||||||
import { JwtStrategy } from "./strategies/jwt.strategy";
|
import { JwtStrategy } from "./strategies/jwt.strategy";
|
||||||
import { NotificationModule } from "../notifications/notifications.module";
|
import { NotificationModule } from "../notifications/notifications.module";
|
||||||
import { ReferralsModule } from "../referrals/referrals.module";
|
import { ReferralsModule } from "../referrals/referrals.module";
|
||||||
|
|
||||||
@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],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, TokensService, JwtStrategy],
|
providers: [AuthService, TokensService, SSOService, SSOClientService, SSOClientRepository, JwtStrategy],
|
||||||
exports: [AuthService],
|
exports: [AuthService, SSOService, SSOClientService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
// }
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -5,4 +5,5 @@ export interface ITokenPayload {
|
|||||||
id: string;
|
id: string;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
permissions: PermissionEnum[];
|
permissions: PermissionEnum[];
|
||||||
|
audience?: string; // Client application identifier for SSO
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<SSOClient[]> {
|
||||||
|
return this.ssoClientRepository.find();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(clientId: string): Promise<SSOClient> {
|
||||||
|
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<SSOClient | null> {
|
||||||
|
return this.ssoClientRepository.findOne({
|
||||||
|
where: { clientId, isActive: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(createDto: {
|
||||||
|
clientId: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
requiresSecret?: boolean;
|
||||||
|
allowedRedirectUrls?: string[];
|
||||||
|
}): Promise<SSOClient> {
|
||||||
|
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<SSOClient> {
|
||||||
|
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<void> {
|
||||||
|
const client = await this.findOne(clientId);
|
||||||
|
await this.ssoClientRepository.remove(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateClientSecret(): string {
|
||||||
|
return randomBytes(32).toString("hex");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string>("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<number>("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<number>("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<number>("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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SSOClient> {
|
||||||
|
constructor(@InjectRepository(SSOClient) repository: Repository<SSOClient>) {
|
||||||
|
super(repository.target, repository.manager, repository.queryRunner);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,7 +48,7 @@ export class BlogCommentsRepository extends Repository<BlogComment> {
|
|||||||
content: true,
|
content: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
status: true,
|
status: true,
|
||||||
user: { id: true, firstName: true, lastName: true },
|
user: { id: true, firstName: true, lastName: true, profilePic: true },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,4 +18,5 @@ export enum PermissionEnum {
|
|||||||
SETTINGS = "settings",
|
SETTINGS = "settings",
|
||||||
BANK_ACCOUNTS = "bank_accounts",
|
BANK_ACCOUNTS = "bank_accounts",
|
||||||
PAYMENTS = "payments",
|
PAYMENTS = "payments",
|
||||||
|
MANAGE_SSO_CLIENTS = "manage_sso_clients",
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user