Skip to content

Commit b5dfd8e

Browse files
authored
Merge pull request #2 from topcoder-platform/PM-1437_use-canActivate
PM-1437 - Use canActivate for tools
2 parents 31b5591 + a2f1683 commit b5dfd8e

File tree

7 files changed

+187
-30
lines changed

7 files changed

+187
-30
lines changed

src/core/auth/auth.constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export enum Role {
2+
Admin = 'administrator',
23
User = 'Topcoder User',
34
}
45

src/core/auth/guards/auth.guard.ts

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,16 @@
1-
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
2-
import { Reflector } from '@nestjs/core';
3-
import { Logger } from 'src/shared/global';
1+
import { Request } from 'express';
2+
import { decodeAuthToken } from './guards.utils';
43

5-
@Injectable()
6-
export class AuthGuard implements CanActivate {
7-
private readonly logger = new Logger(AuthGuard.name);
8-
9-
constructor(private reflector: Reflector) {}
10-
11-
canActivate(context: ExecutionContext): boolean {
12-
this.logger.log('AuthGuard canActivate called...');
13-
// Check if the route is marked as public...
14-
15-
return true;
4+
/**
5+
* Auth guard function to validate the authorization token from the request headers.
6+
*
7+
* @param req - The incoming HTTP request object.
8+
* @returns A promise that resolves to `true` if the authorization token is valid, otherwise `false`.
9+
*/
10+
export const authGuard = async (req: Request) => {
11+
if (!(await decodeAuthToken(req.headers.authorization ?? ''))) {
12+
return false;
1613
}
17-
}
14+
15+
return true;
16+
};

src/core/auth/guards/guards.utils.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import * as jwt from 'jsonwebtoken';
2+
import { Logger } from 'src/shared/global';
3+
import { getSigningKey } from '../jwt';
4+
5+
const logger = new Logger('guards.utils()');
6+
7+
/**
8+
* Decodes and verifies a JWT token from the provided authorization header.
9+
*
10+
* @param authHeader - The authorization header containing the token, expected in the format "Bearer <token>".
11+
* @returns A promise that resolves to the decoded JWT payload if the token is valid,
12+
* a string if the payload is a string, or `false` if the token is invalid or the header is improperly formatted.
13+
*
14+
* @throws This function does not throw directly but will return `false` if an error occurs during verification.
15+
*/
16+
export const decodeAuthToken = async (
17+
authHeader: string,
18+
): Promise<boolean | jwt.JwtPayload | string> => {
19+
const [type, idToken] = authHeader?.split(' ') ?? [];
20+
21+
if (type !== 'Bearer' || !idToken) {
22+
return false;
23+
}
24+
25+
let decoded: jwt.JwtPayload | string;
26+
try {
27+
const signingKey = await getSigningKey(idToken);
28+
decoded = jwt.verify(idToken, signingKey);
29+
} catch (error) {
30+
logger.error('Error verifying JWT', error);
31+
return false;
32+
}
33+
34+
return decoded;
35+
};

src/core/auth/guards/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
export * from './auth.guard';
2+
export * from './m2m-scope.guard';
3+
export * from './role.guard';
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { Request } from 'express';
2+
import { decodeAuthToken } from './guards.utils';
3+
import { JwtPayload } from 'jsonwebtoken';
4+
import { M2mScope } from '../auth.constants';
5+
6+
/**
7+
* A utility function to check if the required M2M (Machine-to-Machine) scopes are present
8+
* in the authorization token provided in the request headers.
9+
*
10+
* @param {...M2mScope[]} requiredM2mScopes - The list of required M2M scopes to validate against.
11+
* @returns {Promise<(req: Request) => boolean>} A function that takes an Express `Request` object
12+
* and returns a boolean indicating whether the required scopes are present.
13+
*
14+
* The function decodes the authorization token from the request headers and checks if
15+
* the required scopes are included in the token's scope claim.
16+
*/
17+
export const checkM2MScope =
18+
(...requiredM2mScopes: M2mScope[]) =>
19+
async (req: Request) => {
20+
const decodedAuth = await decodeAuthToken(req.headers.authorization ?? '');
21+
22+
const authorizedScopes = ((decodedAuth as JwtPayload).scope ?? '').split(
23+
' ',
24+
);
25+
if (!requiredM2mScopes.some((scope) => authorizedScopes.includes(scope))) {
26+
return false;
27+
}
28+
29+
return true;
30+
};

src/core/auth/guards/role.guard.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { Request } from 'express';
2+
import { decodeAuthToken } from './guards.utils';
3+
import { Role } from '../auth.constants';
4+
5+
/**
6+
* A utility function to check if the required user role are present
7+
* in the authorization token provided in the request headers.
8+
*
9+
* @param {...Role[]} requiredUserRoles - The list of required user roles to validate against.
10+
* @returns {Promise<(req: Request) => boolean>} A function that takes an Express `Request` object
11+
* and returns a boolean indicating whether the required scopes are present.
12+
*
13+
* The function decodes the authorization token from the request headers and checks if
14+
* the required user roles are included in the token's scope claim.
15+
*/
16+
export const checkHasUserRole =
17+
(...requiredUserRoles: Role[]) =>
18+
async (req: Request) => {
19+
const decodedAuth = await decodeAuthToken(req.headers.authorization ?? '');
20+
21+
const decodedUserRoles = Object.keys(decodedAuth).reduce((roles, key) => {
22+
if (key.match(/claims\/roles$/gi)) {
23+
return decodedAuth[key] as string[];
24+
}
25+
26+
return roles;
27+
}, []);
28+
29+
if (!requiredUserRoles.some((role) => decodedUserRoles.includes(role))) {
30+
return false;
31+
}
32+
33+
return true;
34+
};

src/mcp/tools/challenges/queryChallenges.tool.ts

Lines changed: 71 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
1-
import { Injectable, Inject, UseGuards } from '@nestjs/common';
1+
import { Injectable, Inject } from '@nestjs/common';
22
import { Tool } from '@tc/mcp-nest';
33
import { REQUEST } from '@nestjs/core';
44
import { QUERY_CHALLENGES_TOOL_PARAMETERS } from './queryChallenges.parameters';
55
import { TopcoderChallengesService } from 'src/shared/topcoder/challenges.service';
66
import { Logger } from 'src/shared/global';
77
import { QUERY_CHALLENGES_TOOL_OUTPUT_SCHEMA } from './queryChallenges.output';
8-
import { AuthGuard } from 'src/core/auth/guards';
8+
import {
9+
authGuard,
10+
checkHasUserRole,
11+
checkM2MScope,
12+
} from 'src/core/auth/guards';
13+
import { M2mScope, Role } from 'src/core/auth/auth.constants';
914

1015
@Injectable()
1116
export class QueryChallengesTool {
@@ -16,19 +21,7 @@ export class QueryChallengesTool {
1621
@Inject(REQUEST) private readonly request: any,
1722
) {}
1823

19-
@Tool({
20-
name: 'query-tc-challenges',
21-
description:
22-
'Returns a list of public Topcoder challenges based on the query parameters.',
23-
parameters: QUERY_CHALLENGES_TOOL_PARAMETERS,
24-
outputSchema: QUERY_CHALLENGES_TOOL_OUTPUT_SCHEMA,
25-
annotations: {
26-
title: 'Query Public Topcoder Challenges',
27-
readOnlyHint: true,
28-
},
29-
})
30-
@UseGuards(AuthGuard)
31-
async queryChallenges(params) {
24+
private async _queryChallenges(params) {
3225
// Validate the input parameters
3326
const validatedParams = QUERY_CHALLENGES_TOOL_PARAMETERS.safeParse(params);
3427
if (!validatedParams.success) {
@@ -127,4 +120,67 @@ export class QueryChallengesTool {
127120
};
128121
}
129122
}
123+
124+
@Tool({
125+
name: 'query-tc-challenges-private',
126+
description:
127+
'Returns a list of public Topcoder challenges based on the query parameters.',
128+
parameters: QUERY_CHALLENGES_TOOL_PARAMETERS,
129+
outputSchema: QUERY_CHALLENGES_TOOL_OUTPUT_SCHEMA,
130+
annotations: {
131+
title: 'Query Public Topcoder Challenges',
132+
readOnlyHint: true,
133+
},
134+
canActivate: authGuard,
135+
})
136+
async queryChallengesPrivate(params) {
137+
return this._queryChallenges(params);
138+
}
139+
140+
@Tool({
141+
name: 'query-tc-challenges-protected',
142+
description:
143+
'Returns a list of public Topcoder challenges based on the query parameters.',
144+
parameters: QUERY_CHALLENGES_TOOL_PARAMETERS,
145+
outputSchema: QUERY_CHALLENGES_TOOL_OUTPUT_SCHEMA,
146+
annotations: {
147+
title: 'Query Public Topcoder Challenges',
148+
readOnlyHint: true,
149+
},
150+
canActivate: checkHasUserRole(Role.Admin),
151+
})
152+
async queryChallengesProtected(params) {
153+
return this._queryChallenges(params);
154+
}
155+
156+
@Tool({
157+
name: 'query-tc-challenges-m2m',
158+
description:
159+
'Returns a list of public Topcoder challenges based on the query parameters.',
160+
parameters: QUERY_CHALLENGES_TOOL_PARAMETERS,
161+
outputSchema: QUERY_CHALLENGES_TOOL_OUTPUT_SCHEMA,
162+
annotations: {
163+
title: 'Query Public Topcoder Challenges',
164+
readOnlyHint: true,
165+
},
166+
canActivate: checkM2MScope(M2mScope.QueryPublicChallenges),
167+
})
168+
async queryChallengesM2m(params) {
169+
return this._queryChallenges(params);
170+
}
171+
172+
@Tool({
173+
name: 'query-tc-challenges-public',
174+
description:
175+
'Returns a list of public Topcoder challenges based on the query parameters.',
176+
parameters: QUERY_CHALLENGES_TOOL_PARAMETERS,
177+
outputSchema: QUERY_CHALLENGES_TOOL_OUTPUT_SCHEMA,
178+
annotations: {
179+
title: 'Query Public Topcoder Challenges',
180+
readOnlyHint: true,
181+
},
182+
})
183+
async queryChallengesPublic(params) {
184+
return this._queryChallenges(params);
185+
}
130186
}

0 commit comments

Comments
 (0)