Skip to content

PM-1437 Feat/auth #4

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jul 10, 2025
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
"@nestjs/common": "^11.0.1",
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1",
"@rekog/mcp-nest": "^1.6.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"dotenv": "^16.5.0",
Expand All @@ -34,6 +33,7 @@
"nanoid": "^5.1.5",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"@tc/mcp-nest": "topcoder-platform/MCP-Nest.git",
"zod": "^3.25.67"
},
"devDependencies": {
Expand Down
13 changes: 7 additions & 6 deletions pnpm-lock.yaml

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

25 changes: 5 additions & 20 deletions src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { McpModule } from '@rekog/mcp-nest';
import { McpModule } from '@tc/mcp-nest';
import { QueryChallengesTool } from './mcp/tools/challenges/queryChallenges.tool';
import { randomUUID } from 'crypto';
import { GlobalProvidersModule } from './shared/global/globalProviders.module';
import { TopcoderModule } from './shared/topcoder/topcoder.module';
import { HealthCheckController } from './api/health-check/healthCheck.controller';
import { TokenValidatorMiddleware } from './core/auth/middleware/tokenValidator.middleware';
import { CreateRequestStoreMiddleware } from './core/request/createRequestStore.middleware';
import { AuthGuard, RolesGuard } from './core/auth/guards';
import { APP_GUARD } from '@nestjs/core';
import { nanoid } from 'nanoid';

@Module({
imports: [
Expand All @@ -17,30 +14,18 @@ import { APP_GUARD } from '@nestjs/core';
version: '1.0.0',
streamableHttp: {
enableJsonResponse: false,
sessionIdGenerator: () => randomUUID(),
sessionIdGenerator: () => nanoid(),
statelessMode: false,
},
// guards: [AuthGuard, RolesGuard],
}),
GlobalProvidersModule,
TopcoderModule,
],
controllers: [HealthCheckController],
providers: [
// {
// provide: APP_GUARD,
// useClass: AuthGuard,
// },
// {
// provide: APP_GUARD,
// useClass: RolesGuard,
// },
QueryChallengesTool,
],
providers: [QueryChallengesTool],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
// consumer.apply(TokenValidatorMiddleware).forRoutes('*');
// consumer.apply(CreateRequestStoreMiddleware).forRoutes('*');
consumer.apply(TokenValidatorMiddleware).forRoutes('*');
}
}
5 changes: 4 additions & 1 deletion src/core/auth/auth.constants.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
export enum Role {
Admin = 'administrator',
User = 'Topcoder User',
}

export enum M2mScope {}
export enum M2mScope {
QueryPublicChallenges = 'query:public:challenges',
}
1 change: 0 additions & 1 deletion src/core/auth/decorators/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
export * from './m2m.decorator';
export * from './m2mScope.decorator';
export * from './public.decorator';
export * from './roles.decorator';
export * from './user.decorator';
5 changes: 0 additions & 5 deletions src/core/auth/decorators/roles.decorator.ts

This file was deleted.

71 changes: 14 additions & 57 deletions src/core/auth/guards/auth.guard.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,16 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { IS_M2M_KEY } from '../decorators/m2m.decorator';
import { M2mScope } from '../auth.constants';
import { SCOPES_KEY } from '../decorators/m2mScope.decorator';

@Injectable()
export class AuthGuard implements CanActivate {
constructor(private reflector: Reflector) {}

canActivate(context: ExecutionContext): boolean {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);

if (isPublic) return true;

const req = context.switchToHttp().getRequest();
const isM2M = this.reflector.getAllAndOverride<boolean>(IS_M2M_KEY, [
context.getHandler(),
context.getClass(),
]);

const { m2mUserId } = req;
if (m2mUserId) {
req.user = {
id: m2mUserId,
handle: '',
};
}

// Regular authentication - check that we have user's email and have verified the id token
if (!isM2M) {
return Boolean(req.email && req.idTokenVerified);
}

// M2M authentication - check scopes
if (!req.idTokenVerified || !req.m2mTokenScope)
throw new UnauthorizedException();

const allowedM2mScopes = this.reflector.getAllAndOverride<M2mScope[]>(
SCOPES_KEY,
[context.getHandler(), context.getClass()],
);

const reqScopes = req.m2mTokenScope.split(' ');
if (reqScopes.some((reqScope) => allowedM2mScopes.includes(reqScope))) {
return true;
}
import { Request } from 'express';
import { decodeAuthToken } from './guards.utils';

/**
* Auth guard function to validate the authorization token from the request headers.
*
* @param req - The incoming HTTP request object.
* @returns A promise that resolves to `true` if the authorization token is valid, otherwise `false`.
*/
export const authGuard = async (req: Request) => {
if (!(await decodeAuthToken(req.headers.authorization ?? ''))) {
return false;
}
}

return true;
};
35 changes: 35 additions & 0 deletions src/core/auth/guards/guards.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import * as jwt from 'jsonwebtoken';
import { Logger } from 'src/shared/global';
import { getSigningKey } from '../jwt';

const logger = new Logger('guards.utils()');

/**
* Decodes and verifies a JWT token from the provided authorization header.
*
* @param authHeader - The authorization header containing the token, expected in the format "Bearer <token>".
* @returns A promise that resolves to the decoded JWT payload if the token is valid,
* a string if the payload is a string, or `false` if the token is invalid or the header is improperly formatted.
*
* @throws This function does not throw directly but will return `false` if an error occurs during verification.
*/
export const decodeAuthToken = async (
authHeader: string,
): Promise<boolean | jwt.JwtPayload | string> => {
const [type, idToken] = authHeader?.split(' ') ?? [];

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high
correctness
The use of optional chaining (authHeader?.split(' ')) may lead to a runtime error if authHeader is undefined. Consider adding a check for authHeader before attempting to split it.


if (type !== 'Bearer' || !idToken) {
return false;
}

let decoded: jwt.JwtPayload | string;
try {
const signingKey = await getSigningKey(idToken);
decoded = jwt.verify(idToken, signingKey);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium
maintainability
The jwt.verify function can throw an error if the token is expired or invalid. While the error is caught, consider handling specific error types to provide more informative logging or responses.

} catch (error) {
logger.error('Error verifying JWT', error);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high
security
Logging the error object directly may expose sensitive information. Consider sanitizing the error message before logging.

return false;
}

return decoded;
};
3 changes: 2 additions & 1 deletion src/core/auth/guards/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './auth.guard';
export * from './roles.guard';
export * from './m2m-scope.guard';
export * from './role.guard';
30 changes: 30 additions & 0 deletions src/core/auth/guards/m2m-scope.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Request } from 'express';
import { decodeAuthToken } from './guards.utils';
import { JwtPayload } from 'jsonwebtoken';
import { M2mScope } from '../auth.constants';

/**
* A utility function to check if the required M2M (Machine-to-Machine) scopes are present
* in the authorization token provided in the request headers.
*
* @param {...M2mScope[]} requiredM2mScopes - The list of required M2M scopes to validate against.
* @returns {Promise<(req: Request) => boolean>} A function that takes an Express `Request` object
* and returns a boolean indicating whether the required scopes are present.
*
* The function decodes the authorization token from the request headers and checks if
* the required scopes are included in the token's scope claim.
*/
export const checkM2MScope =
(...requiredM2mScopes: M2mScope[]) =>
async (req: Request) => {
const decodedAuth = await decodeAuthToken(req.headers.authorization ?? '');

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium
correctness
The use of req.headers.authorization ?? '' assumes that an empty string is a valid input for decodeAuthToken. Consider handling cases where the authorization header is missing more explicitly, potentially returning an error or a specific response.


const authorizedScopes = ((decodedAuth as JwtPayload).scope ?? '').split(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium
correctness
The scope claim is split by spaces, which assumes that scopes are space-separated. Ensure this assumption aligns with the JWT format used in your application, as different formats might use different separators.

' ',
);
if (!requiredM2mScopes.some((scope) => authorizedScopes.includes(scope))) {
return false;
}

return true;
};
34 changes: 34 additions & 0 deletions src/core/auth/guards/role.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Request } from 'express';
import { decodeAuthToken } from './guards.utils';
import { Role } from '../auth.constants';

/**
* A utility function to check if the required user role are present
* in the authorization token provided in the request headers.
*
* @param {...Role[]} requiredUserRoles - The list of required user roles to validate against.
* @returns {Promise<(req: Request) => boolean>} A function that takes an Express `Request` object
* and returns a boolean indicating whether the required scopes are present.
*
* The function decodes the authorization token from the request headers and checks if
* the required user roles are included in the token's scope claim.
*/
export const checkHasUserRole =
(...requiredUserRoles: Role[]) =>
async (req: Request) => {
const decodedAuth = await decodeAuthToken(req.headers.authorization ?? '');

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high
correctness
Consider handling the case where decodeAuthToken returns null or an unexpected structure to avoid runtime errors. This could be done by adding a check after decoding the token.


const decodedUserRoles = Object.keys(decodedAuth).reduce((roles, key) => {
if (key.match(/claims\/roles$/gi)) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium
correctness
The use of Object.keys(decodedAuth) and reduce may lead to unexpected behavior if decodedAuth is not an object or if the keys do not match the expected pattern. Consider validating the structure of decodedAuth before processing.

return decodedAuth[key] as string[];
}

return roles;
}, []);

if (!requiredUserRoles.some((role) => decodedUserRoles.includes(role))) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium
performance
The use of some with includes could be optimized for performance by using a set for decodedUserRoles if the list is large, as includes has O(n) complexity.

return false;
}

return true;
};
57 changes: 0 additions & 57 deletions src/core/auth/guards/roles.guard.ts

This file was deleted.

14 changes: 0 additions & 14 deletions src/core/request/createRequestStore.middleware.ts

This file was deleted.

Loading