Skip to content

PM-1437 - Use canActivate for tools #2

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

Open
wants to merge 2 commits into
base: feat/auth
Choose a base branch
from
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
1 change: 1 addition & 0 deletions src/core/auth/auth.constants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export enum Role {
Admin = 'administrator',
User = 'Topcoder User',
}

Expand Down
29 changes: 14 additions & 15 deletions src/core/auth/guards/auth.guard.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Logger } from 'src/shared/global';
import { Request } from 'express';
import { decodeAuthToken } from './guards.utils';

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

constructor(private reflector: Reflector) {}

canActivate(context: ExecutionContext): boolean {
this.logger.log('AuthGuard canActivate called...');
// Check if the route is marked as public...

return true;
/**
* 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(' ') ?? [];

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

let decoded: jwt.JwtPayload | string;
try {
const signingKey = await getSigningKey(idToken);
decoded = jwt.verify(idToken, signingKey);
} catch (error) {
logger.error('Error verifying JWT', error);
return false;
}

return decoded;
};
2 changes: 2 additions & 0 deletions src/core/auth/guards/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export * from './auth.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 ?? '');

const authorizedScopes = ((decodedAuth as JwtPayload).scope ?? '').split(
' ',
);
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 ?? '');

const decodedUserRoles = Object.keys(decodedAuth).reduce((roles, key) => {
if (key.match(/claims\/roles$/gi)) {
return decodedAuth[key] as string[];
}

return roles;
}, []);

if (!requiredUserRoles.some((role) => decodedUserRoles.includes(role))) {
return false;
}

return true;
};
86 changes: 71 additions & 15 deletions src/mcp/tools/challenges/queryChallenges.tool.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { Injectable, Inject, UseGuards } from '@nestjs/common';
import { Injectable, Inject } from '@nestjs/common';
import { Tool } from '@tc/mcp-nest';
import { REQUEST } from '@nestjs/core';
import { QUERY_CHALLENGES_TOOL_PARAMETERS } from './queryChallenges.parameters';
import { TopcoderChallengesService } from 'src/shared/topcoder/challenges.service';
import { Logger } from 'src/shared/global';
import { QUERY_CHALLENGES_TOOL_OUTPUT_SCHEMA } from './queryChallenges.output';
import { AuthGuard } from 'src/core/auth/guards';
import {
authGuard,
checkHasUserRole,
checkM2MScope,
} from 'src/core/auth/guards';
import { M2mScope, Role } from 'src/core/auth/auth.constants';

@Injectable()
export class QueryChallengesTool {
Expand All @@ -16,19 +21,7 @@ export class QueryChallengesTool {
@Inject(REQUEST) private readonly request: any,
) {}

@Tool({
name: 'query-tc-challenges',
description:
'Returns a list of public Topcoder challenges based on the query parameters.',
parameters: QUERY_CHALLENGES_TOOL_PARAMETERS,
outputSchema: QUERY_CHALLENGES_TOOL_OUTPUT_SCHEMA,
annotations: {
title: 'Query Public Topcoder Challenges',
readOnlyHint: true,
},
})
@UseGuards(AuthGuard)
async queryChallenges(params) {
private async _queryChallenges(params) {
// Validate the input parameters
const validatedParams = QUERY_CHALLENGES_TOOL_PARAMETERS.safeParse(params);
if (!validatedParams.success) {
Expand Down Expand Up @@ -127,4 +120,67 @@ export class QueryChallengesTool {
};
}
}

@Tool({
name: 'query-tc-challenges-private',
description:
'Returns a list of public Topcoder challenges based on the query parameters.',
parameters: QUERY_CHALLENGES_TOOL_PARAMETERS,
outputSchema: QUERY_CHALLENGES_TOOL_OUTPUT_SCHEMA,
annotations: {
title: 'Query Public Topcoder Challenges',
readOnlyHint: true,
},
canActivate: authGuard,
})
async queryChallengesPrivate(params) {
return this._queryChallenges(params);
}

@Tool({
name: 'query-tc-challenges-protected',
description:
'Returns a list of public Topcoder challenges based on the query parameters.',
parameters: QUERY_CHALLENGES_TOOL_PARAMETERS,
outputSchema: QUERY_CHALLENGES_TOOL_OUTPUT_SCHEMA,
annotations: {
title: 'Query Public Topcoder Challenges',
readOnlyHint: true,
},
canActivate: checkHasUserRole(Role.Admin),
})
async queryChallengesProtected(params) {
return this._queryChallenges(params);
}

@Tool({
name: 'query-tc-challenges-m2m',
description:
'Returns a list of public Topcoder challenges based on the query parameters.',
parameters: QUERY_CHALLENGES_TOOL_PARAMETERS,
outputSchema: QUERY_CHALLENGES_TOOL_OUTPUT_SCHEMA,
annotations: {
title: 'Query Public Topcoder Challenges',
readOnlyHint: true,
},
canActivate: checkM2MScope(M2mScope.QueryPublicChallenges),
})
async queryChallengesM2m(params) {
return this._queryChallenges(params);
}

@Tool({
name: 'query-tc-challenges-public',
description:
'Returns a list of public Topcoder challenges based on the query parameters.',
parameters: QUERY_CHALLENGES_TOOL_PARAMETERS,
outputSchema: QUERY_CHALLENGES_TOOL_OUTPUT_SCHEMA,
annotations: {
title: 'Query Public Topcoder Challenges',
readOnlyHint: true,
},
})
async queryChallengesPublic(params) {
return this._queryChallenges(params);
}
}