chore(api): prevent non-staff exam authz token gen on staging (#61786)

Co-authored-by: Huyen Nguyen <25715018+huyenltnguyen@users.noreply.github.com>
This commit is contained in:
Shaun Hamilton
2025-08-13 17:33:14 +02:00
committed by GitHub
parent 59aa011b48
commit db9b7d2358
5 changed files with 89 additions and 5 deletions
@@ -20,7 +20,8 @@ jest.mock('../../utils/env', () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return {
...jest.requireActual('../../utils/env'),
FCC_ENABLE_EXAM_ENVIRONMENT: 'true'
FCC_ENABLE_EXAM_ENVIRONMENT: 'true',
DEPLOYMENT_ENV: 'production'
};
});
+59
View File
@@ -29,6 +29,19 @@ import { getMsTranscriptApiUrl } from './user';
const mockedFetch = jest.fn();
jest.spyOn(globalThis, 'fetch').mockImplementation(mockedFetch);
let mockDeploymentEnv = 'staging';
jest.mock('../../utils/env', () => {
const actualEnv = jest.requireActual('../../utils/env');
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return {
...actualEnv,
get DEPLOYMENT_ENV() {
return mockDeploymentEnv;
},
JWT_SECRET: actualEnv.JWT_SECRET
};
});
// This is used to build a test user.
const testUserData: Prisma.userCreateInput = {
...createUserInput(defaultUserEmail),
@@ -1273,6 +1286,14 @@ Thanks and regards,
});
describe('/user/exam-environment/token', () => {
beforeEach(() => {
mockDeploymentEnv = 'production';
});
afterAll(() => {
mockDeploymentEnv = 'staging';
});
afterEach(async () => {
await fastifyTestInstance.prisma.examEnvironmentAuthorizationToken.deleteMany(
{
@@ -1335,6 +1356,44 @@ Thanks and regards,
);
expect(tokens).toHaveLength(1);
});
test('POST does not generate a new token in non-production environments for non-staff', async () => {
// Override deployment environment for this test
mockDeploymentEnv = 'development';
const response = await superPost('/user/exam-environment/token');
expect(response.status).toBe(403);
});
test('POST does generate a new token in non-production environments for staff', async () => {
// Override deployment environment for this test
mockDeploymentEnv = 'staging';
await fastifyTestInstance.prisma.user.update({
where: {
id: defaultUserId
},
data: { email: 'camperbot@freecodecamp.org' }
});
const response = await superPost('/user/exam-environment/token');
const { examEnvironmentAuthorizationToken } = response.body;
const decodedToken = jwt.decode(examEnvironmentAuthorizationToken);
expect(decodedToken).toStrictEqual({
examEnvironmentAuthorizationToken:
expect.stringMatching(/^[a-z0-9]{24}$/),
iat: expect.any(Number)
});
expect(() =>
jwt.verify(examEnvironmentAuthorizationToken, 'wrong-secret')
).toThrow();
expect(() =>
jwt.verify(examEnvironmentAuthorizationToken, JWT_SECRET)
).not.toThrow();
expect(response.status).toBe(201);
});
});
});
+21 -2
View File
@@ -27,11 +27,12 @@ import {
getPoints,
ProgressTimestamp
} from '../../utils/progress';
import { JWT_SECRET } from '../../utils/env';
import { DEPLOYMENT_ENV, JWT_SECRET } from '../../utils/env';
import {
getExamAttemptHandler,
getExamAttemptsHandler
} from '../../exam-environment/routes/exam-environment';
import { ERRORS } from '../../exam-environment/utils/errors';
/**
* Helper function to get the api url from the shared transcript link.
@@ -498,7 +499,7 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
done();
};
// eslint-disable-next-line jsdoc/require-param
// eslint-disable-next-line jsdoc/require-param, jsdoc/require-returns
/**
* Generate a new authorization token for the given user, and invalidates any existing tokens.
*
@@ -515,6 +516,24 @@ async function examEnvironmentTokenHandler(
if (!userId) {
throw new Error('Unreachable. User should be authenticated.');
}
// In non-production environments, only staff are allowed to generate a token
if (
DEPLOYMENT_ENV !== 'production' &&
(!req.user?.email?.endsWith('@freecodecamp.org') ||
!req.user?.emailVerified)
) {
logger.info(
`User not allowed to generate authorization token on ${DEPLOYMENT_ENV}.`
);
void reply.code(403);
return reply.send(
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(
`User not allowed to generate authorization token in ${DEPLOYMENT_ENV} environment.`
)
);
}
// Delete (invalidate) any existing tokens for the user.
await this.prisma.examEnvironmentAuthorizationToken.deleteMany({
where: {
@@ -1,9 +1,12 @@
import { Type } from '@fastify/type-provider-typebox';
import { STANDARD_ERROR } from '../../exam-environment/utils/errors';
export const userExamEnvironmentToken = {
response: {
200: Type.Object({
201: Type.Object({
examEnvironmentAuthorizationToken: Type.String()
})
}),
403: STANDARD_ERROR
// default: STANDARD_ERROR
}
};
+2
View File
@@ -47,6 +47,7 @@ If so, ensure that the environment variable JEST_WORKER_ID is set.`
assert.ok(process.env.HOME_LOCATION);
assert.ok(isAllowedEnv(_FREECODECAMP_NODE_ENV));
assert.ok(process.env.DEPLOYMENT_ENV);
assert.ok(isAllowedProvider(_EMAIL_PROVIDER));
assert.ok(process.env.AUTH0_CLIENT_ID);
assert.ok(process.env.AUTH0_CLIENT_SECRET);
@@ -191,6 +192,7 @@ export const FCC_ENABLE_SENTRY_ROUTES = undefinedOrBool(
process.env.FCC_ENABLE_SENTRY_ROUTES
);
export const FREECODECAMP_NODE_ENV = _FREECODECAMP_NODE_ENV;
export const DEPLOYMENT_ENV = process.env.DEPLOYMENT_ENV;
export const SENTRY_DSN =
process.env.SENTRY_DSN === 'dsn_from_sentry_dashboard'
? ''