Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/prompts/autonomy.manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"version": "2025.11",
"consent": {
"phrase": "",
"expiresMinutes": 0
},
"actions": []
}
34 changes: 0 additions & 34 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { ReadReplicaModule } from './database/read-replica';
import { CachingModule } from './caching/caching.module';
import { CoursesModule } from './courses/courses.module';
import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';
import { CohortsModule } from './cohorts/cohorts.module';

const featureFlags = loadFeatureFlags();
Expand Down Expand Up @@ -67,6 +68,8 @@ const featureFlags = loadFeatureFlags();
// ✅ courses module with enrollment and prerequisite enforcement
CoursesModule,
CohortsModule,

UsersModule,
],
controllers: [AppController],
providers: [
Expand Down
3 changes: 0 additions & 3 deletions src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,6 @@ import { PermissionsGuard } from './guards/permissions.guard';
import { SocialAuthService } from './services/social-auth.service';
import { SocialAuthController } from './controllers/social-auth.controller';

/**
* Registers the authentication module with Passport and JWT support.
*/
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
Expand Down
25 changes: 9 additions & 16 deletions src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,14 @@ import { Injectable, UnauthorizedException, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { v4 as uuidv4 } from 'uuid';
import { randomUUID } from 'crypto';
import * as bcrypt from 'bcrypt';
import { User } from '../users/entities/user.entity';
import { TokenBlacklistService } from './services/token-blacklist.service';

@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);

// Default refresh token expiration (7 days)
private readonly refreshTokenExpiryMs = 7 * 24 * 60 * 60 * 1000;

constructor(
Expand All @@ -21,22 +19,15 @@ export class AuthService {
private readonly tokenBlacklistService: TokenBlacklistService,
) {}

/**
* Generates tokens for the user and saves the refresh token hash.
*/
async login(user: User) {
const tokens = await this.generateTokens(user);
await this.updateRefreshTokenHash(user.id, tokens.refreshToken);
return tokens;
}

/**
* Refreshes the tokens if the provided refresh token is valid and not blacklisted.
*/
async refreshTokens(refreshToken: string) {
let decoded: any;
try {
// Verify token signature and expiration
decoded = this.jwtService.verify(refreshToken, {
secret: process.env.JWT_REFRESH_SECRET || 'default-refresh-secret',
});
Expand All @@ -62,24 +53,20 @@ export class AuthService {
throw new UnauthorizedException('Invalid token format');
}

// Check blacklist
const isBlacklisted = await this.tokenBlacklistService.isBlacklisted(jti);
if (isBlacklisted) {
// Token reuse detected. We should invalidate the current active session.
this.logger.warn(
`Revoked refresh token reuse detected for user ${userId}. Revoking current active token.`,
);
await this.revokeUserTokens(userId);
throw new UnauthorizedException('Token has been revoked');
}

// Automatically invalidate the old token (rotation)
const expiresInMs = decoded.exp * 1000 - Date.now();
if (expiresInMs > 0) {
await this.tokenBlacklistService.addToBlacklist(jti, expiresInMs);
}

// Issue new tokens
const tokens = await this.generateTokens(user);
await this.updateRefreshTokenHash(user.id, tokens.refreshToken);
return tokens;
Expand All @@ -106,8 +93,14 @@ export class AuthService {
}

private async generateTokens(user: User) {
const payload = { sub: user.id, email: user.email, role: user.role };
const refreshJti = uuidv4();
const accessJti = randomUUID();
const refreshJti = randomUUID();
const payload = {
sub: user.id,
email: user.email,
role: (user.roles?.[0]?.name ?? '') as string,
jti: accessJti,
};

const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(payload, {
Expand Down
87 changes: 35 additions & 52 deletions src/auth/jwt.strategy.ts
Original file line number Diff line number Diff line change
@@ -1,65 +1,48 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from '../users/entities/user.entity';
import { passportJwtSecret } from 'jwks-rsa';

export interface JwtPayload {
sub: string;
email: string;
roles: string[];
permissions: string[];
}

/**
* Passport JWT strategy for validating Bearer tokens.
*/
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_SECRET || 'default-jwt-secret',
});
}
private readonly logger = new Logger(JwtStrategy.name);

constructor() {
const audience = process.env.AUTH0_AUDIENCE;
const issuerUrl = process.env.AUTH0_ISSUER_URL;

/**
* Validates the decoded JWT payload and returns the user object.
* @param payload The decoded JWT payload.
* @returns The authenticated user with roles and permissions.
*/
async validate(payload: JwtPayload): Promise<any> {
const user = await this.userRepository.findOneBy({ id: payload.sub });
if (!user) {
throw new Error('User not found');
if (!audience) {
throw new Error('AUTH0_AUDIENCE is not defined in the environment variables.');
}
if (!issuerUrl) {
throw new Error('AUTH0_ISSUER_URL is not defined in the environment variables.');
}

// Fetch roles and permissions for the user
const userWithRolesAndPermissions = await this.userRepository
.createQueryBuilder('user')
.leftJoinAndSelect('user.roles', 'role')
.leftJoinAndSelect('role.permissions', 'permission')
.where('user.id = :id', { id: user.id })
.getOne();
const normalizedIssuer = issuerUrl.endsWith('/') ? issuerUrl : `${issuerUrl}/`;
const jwksUri = `${normalizedIssuer}.well-known/jwks.json`;

if (!userWithRolesAndPermissions) {
throw new Error('User not found');
}
super({
secretOrKeyProvider: passportJwtSecret({
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 5,
jwksUri,
}),
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
audience,
issuer: normalizedIssuer,
algorithms: ['RS256'],
});

const roles = userWithRolesAndPermissions.roles.map((role) => role.name);
const permissions = userWithRolesAndPermissions.roles.reduce((acc, role) => {
return acc.concat(role.permissions.map((p) => `${p.resource}:${p.action}`));
}, [] as string[]);
this.logger.log(
`Auth0 JwtStrategy successfully initialized with audience [${audience}] and issuer [${normalizedIssuer}]`,
);
}

return {
...payload,
roles,
permissions,
};
async validate(payload: any): Promise<any> {
if (!payload) {
throw new UnauthorizedException('Invalid token payload');
}
return payload;
}
}
9 changes: 6 additions & 3 deletions src/courses/courses.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ export class CoursesService {
*/
async findAll(requestingUser?: User): Promise<Course[]> {
const isPrivileged =
requestingUser && [UserRole.ADMIN, UserRole.MODERATOR].includes(requestingUser.role);
requestingUser &&
requestingUser.roles.some((r) => r.name === UserRole.ADMIN || r.name === UserRole.MODERATOR);

if (isPrivileged) {
return this.courseRepo.find({ order: { createdAt: 'DESC' } });
Expand Down Expand Up @@ -350,14 +351,16 @@ export class CoursesService {
}

private assertPrivileged(user: User): void {
if (![UserRole.ADMIN, UserRole.MODERATOR].includes(user.role)) {
if (!user.roles.some((r) => r.name === UserRole.ADMIN || r.name === UserRole.MODERATOR)) {
throw new ForbiddenOperationException('Only admins or moderators may perform this action.');
}
}

private assertOwnerOrPrivileged(course: Course, user: User): void {
const isOwner = course.instructorId === user.id;
const isPrivileged = [UserRole.ADMIN, UserRole.MODERATOR].includes(user.role);
const isPrivileged = user.roles.some(
(r) => r.name === UserRole.ADMIN || r.name === UserRole.MODERATOR,
);
if (!isOwner && !isPrivileged) {
throw new ForbiddenOperationException('Insufficient permissions.');
}
Expand Down
2 changes: 1 addition & 1 deletion src/courses/enrollments.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ export class EnrollmentsService {
* Check admin/moderator role.
*/
private isPrivileged(user: User): boolean {
return [UserRole.ADMIN, UserRole.MODERATOR].includes(user.role);
return user.roles.some((r) => r.name === UserRole.ADMIN || r.name === UserRole.MODERATOR);
}

/**
Expand Down
9 changes: 6 additions & 3 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger, VersioningType } from '@nestjs/common';
import { NestFactory, Reflector } from '@nestjs/core';
import { ClassSerializerInterceptor, ValidationPipe, Logger, VersioningType } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import cluster from 'node:cluster';
import { cpus } from 'node:os';
Expand Down Expand Up @@ -308,7 +308,10 @@ async function bootstrapWorker(): Promise<void> {
// =========================
// GLOBAL TIMEZONE + LOCALE ENFORCEMENT (IMPORTANT FIX)
// =========================
app.useGlobalInterceptors(new LocaleInterceptor());
app.useGlobalInterceptors(
new LocaleInterceptor(),
new ClassSerializerInterceptor(app.get(Reflector)),
);

// =========================
// SWAGGER
Expand Down
2 changes: 1 addition & 1 deletion src/profile-completeness/profile-completeness.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const SCORED_FIELDS: Array<{ field: keyof User; label: string; points: number }>
{ field: 'username', label: 'Username', points: 10 },
{ field: 'profilePicture', label: 'Profile picture', points: 20 },
{ field: 'isEmailVerified', label: 'Email verified', points: 20 },
{ field: 'role', label: 'Role set', points: 10 },
{ field: 'roles', label: 'Role set', points: 10 },
{ field: 'lastLoginAt', label: 'Logged in at least once', points: 10 },
{ field: 'tenantId', label: 'Organisation linked', points: 10 },
];
Expand Down
21 changes: 21 additions & 0 deletions src/users/controllers/users.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Controller, Get, Query, UseGuards, Request } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { UsersService } from '../services/users.service';
import { GetUsersDto } from '../dto/get-users.dto';

@ApiTags('users')
@Controller('users')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class UsersController {
constructor(private readonly usersService: UsersService) {}

@Get('search')
@ApiOperation({ summary: 'Search users' })
@ApiResponse({ status: 200, description: 'Returns paginated user search results' })
async search(@Request() req, @Query() query: GetUsersDto) {
const isAdmin = req.user?.roles?.includes('admin');
return this.usersService.search(query, req.user, isAdmin);
}
}
39 changes: 39 additions & 0 deletions src/users/dto/user-admin.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { ApiProperty } from '@nestjs/swagger';

export class UserAdminDto {
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
id: string;

@ApiProperty({ example: 'jane@example.com' })
email: string;

@ApiProperty({ example: 'janedoe', required: false })
username?: string;

@ApiProperty({ example: 'Jane' })
firstName: string;

@ApiProperty({ example: 'Doe' })
lastName: string;

@ApiProperty({ example: 'active' })
status: string;

@ApiProperty({ example: 'tenant-123', required: false })
tenantId?: string;

@ApiProperty({ example: 'https://cdn.teachlink.com/avatars/jane.jpg', required: false })
profilePicture?: string;

@ApiProperty({ example: true })
isEmailVerified: boolean;

@ApiProperty({ example: ['admin'] })
roles: string[];

@ApiProperty({ example: '2024-01-01T00:00:00.000Z' })
createdAt: Date;

@ApiProperty({ example: '2024-01-02T00:00:00.000Z' })
updatedAt: Date;
}
Loading
Loading