chore: add sso service and logic

This commit is contained in:
mahyargdz
2025-04-21 10:41:43 +03:30
parent 41b0652c47
commit 160040a4b7
16 changed files with 619 additions and 9 deletions
+7 -2
View File
@@ -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
+1 -1
View File
@@ -13,7 +13,7 @@ export function databaseConfigs(): TypeOrmModuleAsyncOptions {
username: configService.getOrThrow<string>("DB_USER"),
password: configService.getOrThrow<string>("DB_PASS"),
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,
migrationsTableName: "typeorm_migrations",
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[];
}
+34 -2
View File
@@ -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",
};
}
}
+17 -3
View File
@@ -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 {}
@@ -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;
isAdmin: boolean;
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");
}
}
+244
View File
@@ -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,
createdAt: 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",
BANK_ACCOUNTS = "bank_accounts",
PAYMENTS = "payments",
MANAGE_SSO_CLIENTS = "manage_sso_clients",
}