diff --git a/src/core/auth/auth.constants.ts b/src/core/auth/auth.constants.ts index 5c96928..e0d1620 100644 --- a/src/core/auth/auth.constants.ts +++ b/src/core/auth/auth.constants.ts @@ -1,4 +1,5 @@ export enum Role { + Admin = 'administrator', User = 'Topcoder User', } diff --git a/src/core/auth/guards/auth.guard.ts b/src/core/auth/guards/auth.guard.ts index b0fc624..a63de76 100644 --- a/src/core/auth/guards/auth.guard.ts +++ b/src/core/auth/guards/auth.guard.ts @@ -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; +}; diff --git a/src/core/auth/guards/guards.utils.ts b/src/core/auth/guards/guards.utils.ts new file mode 100644 index 0000000..cd83d82 --- /dev/null +++ b/src/core/auth/guards/guards.utils.ts @@ -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 ". + * @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 => { + 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; +}; diff --git a/src/core/auth/guards/index.ts b/src/core/auth/guards/index.ts index b41e34a..49afb20 100644 --- a/src/core/auth/guards/index.ts +++ b/src/core/auth/guards/index.ts @@ -1 +1,3 @@ export * from './auth.guard'; +export * from './m2m-scope.guard'; +export * from './role.guard'; diff --git a/src/core/auth/guards/m2m-scope.guard.ts b/src/core/auth/guards/m2m-scope.guard.ts new file mode 100644 index 0000000..b1d85a5 --- /dev/null +++ b/src/core/auth/guards/m2m-scope.guard.ts @@ -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; + }; diff --git a/src/core/auth/guards/role.guard.ts b/src/core/auth/guards/role.guard.ts new file mode 100644 index 0000000..15fbc50 --- /dev/null +++ b/src/core/auth/guards/role.guard.ts @@ -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; + }; diff --git a/src/mcp/tools/challenges/queryChallenges.tool.ts b/src/mcp/tools/challenges/queryChallenges.tool.ts index 328556f..ed55795 100644 --- a/src/mcp/tools/challenges/queryChallenges.tool.ts +++ b/src/mcp/tools/challenges/queryChallenges.tool.ts @@ -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 { @@ -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) { @@ -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); + } }