Skip to content
Merged
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
53 changes: 50 additions & 3 deletions package-lock.json

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

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@
"helmet": "^8.0.0",
"ioredis": "^5.9.3",
"joi": "^18.1.2",
"jwks-rsa": "^4.0.1",
"multer": "^2.0.1",
"murmurhash-js": "^1.0.0",
"nodemailer": "^7.0.12",
Expand Down
7 changes: 4 additions & 3 deletions src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from '../users/entities/user.entity';
import { JwtStrategy } from './jwt.strategy';
import { JwtStrategy } from './strategies/jwt.strategy';
import { RolesGuard } from './guards/roles.guard';
import { PermissionsGuard } from './guards/permissions.guard';

/**
* Registers the authentication module with Passport and JWT support.
* Registers the authentication module with Passport and Auth0 JWT support.
* Bundles PassportModule and registers the dynamic Auth0 JWKS JWT strategy.
*/
@Module({
imports: [
Expand All @@ -20,6 +21,6 @@ import { PermissionsGuard } from './guards/permissions.guard';
TypeOrmModule.forFeature([User]),
],
providers: [JwtStrategy, RolesGuard, PermissionsGuard],
exports: [PassportModule, JwtModule, RolesGuard, PermissionsGuard],
exports: [PassportModule, JwtModule, JwtStrategy, RolesGuard, PermissionsGuard],
})
export class AuthModule {}
8 changes: 8 additions & 0 deletions src/auth/guards/auth0.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

/**
* Guard that secures routes using Auth0 Bearer token validation (Passport 'jwt' strategy).
*/
@Injectable()
export class Auth0Guard extends AuthGuard('jwt') {}
99 changes: 89 additions & 10 deletions src/auth/guards/roles.guard.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,115 @@
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException, Logger } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';

/**
* Protects roles execution paths.
* Protects execution paths based on roles extracted from the user object.
* Evaluates custom RBAC roles passed from Auth0 token custom claims or local user properties.
*/
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
private readonly logger = new Logger(RolesGuard.name);

constructor(private readonly reflector: Reflector) {}

/**
* Executes can Activate.
* @param context The context.
* @returns Whether the operation succeeded.
* Evaluates if the current user has the required roles.
* @param context The execution context.
* @returns Whether the operation is allowed.
*/
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);

if (!requiredRoles) {
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}

const request = context.switchToHttp().getRequest();
const user = request.user;

if (!user) {
throw new UnauthorizedException();
this.logger.warn('Access denied: User object is missing from the request context.');
throw new UnauthorizedException('Authentication required');
}

// Extract roles from Auth0 custom claims, standard claims, or local user properties
const userRoles = this.extractRoles(user);

const hasRole = requiredRoles.some((role) => userRoles.includes(role.toLowerCase()));

if (!hasRole) {
this.logger.warn(
`Access denied: User does not possess any of the required roles [${requiredRoles.join(', ')}]. Extracted user roles: [${userRoles.join(', ')}]`,
);
}

return hasRole;
}

/**
* Safely extracts roles from the user object.
* Supports Auth0 custom claims namespace, standard roles array (strings or Role entities), standard role string, and local user role.
* @param user Decoded user token payload or user entity.
* @returns Array of extracted user roles.
*/
private extractRoles(user: any): string[] {
const roles: string[] = [];

// 1. Check Auth0 custom claims (e.g. https://api.teachlink.com/roles)
const audience = process.env.AUTH0_AUDIENCE || 'https://api.teachlink.com';
const namespacedClaims = [
`${audience}/roles`,
`${audience}/role`,
'https://teachlink.com/roles',
'https://teachlink.com/role',
];

for (const claim of namespacedClaims) {
const claimVal = user[claim];
if (claimVal) {
if (Array.isArray(claimVal)) {
roles.push(...claimVal);
} else if (typeof claimVal === 'string') {
roles.push(claimVal);
}
}
}

// 2. Check standard 'roles' property (array of strings or Role entities)
if (user.roles && Array.isArray(user.roles)) {
for (const r of user.roles) {
if (typeof r === 'string') {
roles.push(r);
} else if (r && typeof r === 'object' && r.name) {
roles.push(r.name);
}
}
} else if (user.roles && typeof user.roles === 'string') {
roles.push(user.roles);
}

// 3. Check standard 'role' property (used in legacy local JWT/DB implementation)
if (user.role) {
if (Array.isArray(user.role)) {
roles.push(...user.role);
} else if (typeof user.role === 'string') {
roles.push(user.role);
} else if (typeof user.role === 'object' && user.role.name) {
roles.push(user.role.name);
}
}

// 4. Check permissions array in Auth0 (fallback)
if (user.permissions && Array.isArray(user.permissions)) {
roles.push(...user.permissions);
}

// Assuming user.roles is an array of role names (strings)
return requiredRoles.some(role => user.roles.includes(role));
// Clean up, normalize to lowercase strings, and filter out empty values
return roles
.map((r) => String(r).trim().toLowerCase())
.filter((r) => r.length > 0);
}
}
66 changes: 66 additions & 0 deletions src/auth/strategies/jwt.strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { passportJwtSecret } from 'jwks-rsa';

/**
* Passport JWT strategy for validating Auth0 Bearer tokens dynamically.
* Resolves signing keys dynamically from the Auth0 Issuer JWKS endpoint.
*/
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
private readonly logger = new Logger(JwtStrategy.name);

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

if (!audience) {
const errorMsg = 'AUTH0_AUDIENCE is not defined in the environment variables.';
const initLogger = new Logger('JwtStrategy');
initLogger.error(errorMsg);
throw new Error(errorMsg);
}

if (!issuerUrl) {
const errorMsg = 'AUTH0_ISSUER_URL is not defined in the environment variables.';
const initLogger = new Logger('JwtStrategy');
initLogger.error(errorMsg);
throw new Error(errorMsg);
}

// Safely construct and normalize the issuer and JWKS URI
const normalizedIssuer = issuerUrl.endsWith('/') ? issuerUrl : `${issuerUrl}/`;
const jwksUri = `${normalizedIssuer}.well-known/jwks.json`;

super({
secretOrKeyProvider: passportJwtSecret({
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 5,
jwksUri,
}),
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
audience,
issuer: normalizedIssuer,
algorithms: ['RS256'],
});

this.logger.log(
`Auth0 JwtStrategy successfully initialized with audience [${audience}] and issuer [${normalizedIssuer}]`,
);
}

/**
* Validates the decoded JWT payload.
* @param payload The decoded JWT payload.
* @returns The payload to be attached to the request object.
*/
async validate(payload: any): Promise<any> {
if (!payload) {
this.logger.warn('Token validation failed: payload is empty or invalid.');
throw new UnauthorizedException('Invalid token payload');
}
return payload;
}
}
Loading
Loading