fix(api): remove authn requirement for coderoad challenges (#60425)

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Shaun Hamilton
2025-05-19 12:53:43 +02:00
committed by GitHub
parent 8558d0b1f1
commit 00264908e8
4 changed files with 196 additions and 145 deletions
+4
View File
@@ -192,6 +192,10 @@ export const build = async (
await fastify.register(protectedRoutes.settingRedirectRoutes);
});
});
// TODO: The route should not handle its own AuthZ
await fastify.register(protectedRoutes.challengeTokenRoutes);
// Routes for signed out users:
void fastify.register(async function (fastify) {
fastify.addHook('onRequest', fastify.authorize);
+29 -2
View File
@@ -6,6 +6,7 @@ const mockVerifyTrophyWithMicrosoft = jest.fn();
import { omit } from 'lodash';
import { Static } from '@fastify/type-provider-typebox';
import { DailyCodingChallengeLanguage } from '@prisma/client';
import request from 'supertest';
import { challengeTypes } from '../../../../shared/config/challenge-types';
import {
@@ -246,7 +247,11 @@ describe('challengeRoutes', () => {
const token = (tokenResponse.body as { userToken: string }).userToken;
const response = await superPost('/coderoad-challenge-completed')
// This route is special since it does not have CSRF protection OR authN
// protection. As such, we use a normal `request` to send the bare
// minimum (no extra headers or cookies).
const response = await request(fastifyTestInstance.server)
.post('/coderoad-challenge-completed')
.set('coderoad-user-token', token)
.send({
tutorialId:
@@ -270,6 +275,28 @@ describe('challengeRoutes', () => {
expect(response.status).toBe(200);
});
test('Should return an error response if something goes wrong', async () => {
jest
.spyOn(fastifyTestInstance.prisma.userToken, 'findUnique')
.mockImplementationOnce(() => {
throw new Error('Database error');
});
const tokenResponse = await superPost('/user/user-token');
const token = (tokenResponse.body as { userToken: string }).userToken;
const response = await superPost('/coderoad-challenge-completed')
.set('coderoad-user-token', token)
.send({
tutorialId: 'freeCodeCamp/learn-celestial-bodies-database:v1.0.0'
});
expect(response.body).toEqual({
msg: 'An error occurred trying to submit the challenge',
type: 'error'
});
expect(response.status).toBe(500);
});
test('Should complete project with code 200', async () => {
const tokenResponse = await superPost('/user/user-token');
expect(tokenResponse.body).toHaveProperty('userToken');
@@ -2049,7 +2076,7 @@ describe('challengeRoutes', () => {
});
const endpoints: { path: string; method: 'POST' | 'GET' }[] = [
{ path: '/coderoad-challenge-completed', method: 'POST' },
// { path: '/coderoad-challenge-completed', method: 'POST' },
{ path: '/project-completed', method: 'POST' },
{ path: '/backend-challenge-completed', method: 'POST' },
{ path: '/modern-challenge-completed', method: 'POST' },
+159 -141
View File
@@ -52,6 +52,8 @@ const userChallengeSelect = {
savedChallenges: true
};
const challenges = getChallenges();
/**
* Plugin for the challenge submission endpoints.
*
@@ -64,147 +66,6 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
_options,
done
) => {
const challenges = getChallenges();
fastify.post(
'/coderoad-challenge-completed',
{
schema: schemas.coderoadChallengeCompleted,
errorHandler(error, req, reply) {
const logger = fastify.log.child({ req, res: reply });
if (error.validation) {
logger.warn({ validationError: error.validation });
void reply.code(400);
return formatCoderoadChallengeCompletedValidation(error.validation);
} else {
fastify.errorHandler(error, req, reply);
}
}
},
async (req, reply) => {
const logger = fastify.log.child({ req, res: reply });
logger.info(
{ userId: req.user?.id },
'User submitted a coderoad challenge'
);
const { 'coderoad-user-token': encodedUserToken } = req.headers;
const { tutorialId } = req.body;
let userToken;
try {
const payload = jwt.verify(encodedUserToken, JWT_SECRET) as JwtPayload;
userToken = payload.userToken;
} catch {
logger.warn('Invalid user token');
void reply.code(400);
return { type: 'error', msg: `invalid user token` } as const;
}
const tutorialRepo = tutorialId.split(':')[0];
const tutorialOrg = tutorialRepo?.split('/')?.[0];
if (tutorialOrg !== 'freeCodeCamp') {
logger.warn(
{ tutorialId },
'Tutorial not hosted on freeCodeCamp GitHub account'
);
void reply.code(400);
return {
type: 'error',
msg: `Tutorial not hosted on freeCodeCamp GitHub account`
} as const;
}
const codeRoadChallenges = challenges.filter(
({ challengeType }) =>
challengeType === challengeTypes.codeAllyPractice ||
challengeType === challengeTypes.codeAllyCert
);
const challenge = codeRoadChallenges.find(challenge => {
return tutorialRepo && challenge.url?.endsWith(tutorialRepo);
});
if (!challenge) {
logger.warn({ tutorialRepo }, 'Tutorial repo is not valid');
void reply.code(400);
return { type: 'error', msg: 'Tutorial name is not valid' } as const;
}
const { id: challengeId, challengeType } = challenge;
try {
const tokenInfo = await fastify.prisma.userToken.findUnique({
where: { id: userToken }
});
if (!tokenInfo) {
logger.warn('User token not found');
void reply.code(400);
return { type: 'error', msg: 'User token not found' } as const;
}
const { userId } = tokenInfo;
const user = await fastify.prisma.user.findFirstOrThrow({
where: { id: userId }
});
if (!user) {
logger.warn('User not found');
void reply.code(400);
return {
type: 'error',
msg: 'User for user token not found'
} as const;
}
const completedDate = Date.now();
const { completedChallenges = [], partiallyCompletedChallenges = [] } =
user;
const isCompleted = completedChallenges.some(
challenge => challenge.id === challengeId
);
if (challengeType === challengeTypes.codeAllyCert && !isCompleted) {
const finalChallenge = {
id: challengeId,
completedDate
};
await fastify.prisma.user.update({
where: { id: req.user?.id },
data: {
partiallyCompletedChallenges: uniqBy(
[finalChallenge, ...partiallyCompletedChallenges],
'id'
)
}
});
} else {
await updateUserChallengeData(fastify, user, challengeId, {
id: challengeId,
completedDate
});
}
} catch (error) {
// TODO(Post-MVP): don't catch, just let Sentry handle this.
logger.error(error, 'Error submitting coderoad challenge');
fastify.Sentry.captureException(error);
void reply.code(400);
return {
type: 'error',
msg: 'An error occurred trying to submit the challenge'
} as const;
}
return {
type: 'success',
msg: 'Successfully submitted challenge'
} as const;
}
);
fastify.post(
'/project-completed',
{
@@ -975,6 +836,163 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
done();
};
/**
* Plugin for challenge submissions behind AuthZ, not AuthN.
*
* @param fastify The Fastify instance.
* @param _options Options passed to the plugin via `fastify.register(plugin, options)`.
* @param done The callback to signal that the plugin is ready.
*/
export const challengeTokenRoutes: FastifyPluginCallbackTypebox = (
fastify,
_options,
done
) => {
fastify.post(
'/coderoad-challenge-completed',
{
schema: schemas.coderoadChallengeCompleted,
errorHandler(error, req, reply) {
const logger = fastify.log.child({ req, res: reply });
if (error.validation) {
logger.warn({ validationError: error.validation });
void reply.code(400);
return formatCoderoadChallengeCompletedValidation(error.validation);
} else {
fastify.errorHandler(error, req, reply);
}
}
},
postCoderoadChallengeCompleted
);
done();
};
async function postCoderoadChallengeCompleted(
this: FastifyInstance,
req: UpdateReqType<typeof schemas.coderoadChallengeCompleted>,
reply: FastifyReply
) {
const logger = this.log.child({ req, res: reply });
logger.info({ userId: req.user?.id }, 'User submitted a coderoad challenge');
const { 'coderoad-user-token': encodedUserToken } = req.headers;
const { tutorialId } = req.body;
let userToken;
try {
const payload = jwt.verify(encodedUserToken, JWT_SECRET) as JwtPayload;
userToken = payload.userToken;
} catch {
logger.warn('Invalid user token');
void reply.code(400);
return reply.send({ type: 'error', msg: `invalid user token` });
}
const tutorialRepo = tutorialId.split(':')[0];
const tutorialOrg = tutorialRepo?.split('/')?.[0];
if (tutorialOrg !== 'freeCodeCamp') {
logger.warn(
{ tutorialId },
'Tutorial not hosted on freeCodeCamp GitHub account'
);
void reply.code(400);
return reply.send({
type: 'error',
msg: `Tutorial not hosted on freeCodeCamp GitHub account`
});
}
const codeRoadChallenges = challenges.filter(
({ challengeType }) =>
challengeType === challengeTypes.codeAllyPractice ||
challengeType === challengeTypes.codeAllyCert
);
const challenge = codeRoadChallenges.find(challenge => {
return tutorialRepo && challenge.url?.endsWith(tutorialRepo);
});
if (!challenge) {
logger.warn({ tutorialRepo }, 'Tutorial repo is not valid');
void reply.code(400);
return reply.send({ type: 'error', msg: 'Tutorial name is not valid' });
}
const { id: challengeId, challengeType } = challenge;
try {
const tokenInfo = await this.prisma.userToken.findUnique({
where: { id: userToken }
});
if (!tokenInfo) {
logger.warn('User token not found');
void reply.code(400);
return reply.send({ type: 'error', msg: 'User token not found' });
}
const { userId } = tokenInfo;
const user = await this.prisma.user.findFirstOrThrow({
where: { id: userId }
});
if (!user) {
logger.warn('User not found');
void reply.code(400);
return {
type: 'error',
msg: 'User for user token not found'
} as const;
}
const completedDate = Date.now();
const { completedChallenges = [], partiallyCompletedChallenges = [] } =
user;
const isCompleted = completedChallenges.some(
challenge => challenge.id === challengeId
);
if (challengeType === challengeTypes.codeAllyCert && !isCompleted) {
const finalChallenge = {
id: challengeId,
completedDate
};
await this.prisma.user.update({
where: { id: userId },
data: {
partiallyCompletedChallenges: uniqBy(
[finalChallenge, ...partiallyCompletedChallenges],
'id'
)
}
});
} else {
await updateUserChallengeData(this, user, challengeId, {
id: challengeId,
completedDate
});
}
} catch (error) {
// TODO(Post-MVP): don't catch, just let Sentry handle this.
logger.error(error, 'Error submitting coderoad challenge');
this.Sentry.captureException(error);
void reply.code(500);
return reply.send({
type: 'error',
msg: 'An error occurred trying to submit the challenge'
});
}
reply.send({
type: 'success',
msg: 'Successfully submitted challenge'
});
}
async function postDailyCodingChallengeCompleted(
this: FastifyInstance,
req: UpdateReqType<typeof schemas.dailyCodingChallengeCompleted>,
@@ -1,5 +1,4 @@
import { Type } from '@fastify/type-provider-typebox';
import { genericError } from '../types';
export const coderoadChallengeCompleted = {
body: Type.Object({
@@ -15,6 +14,9 @@ export const coderoadChallengeCompleted = {
type: Type.Literal('error'),
msg: Type.String()
}),
default: genericError
default: Type.Object({
type: Type.Literal('error'),
msg: Type.String()
})
}
};