diff --git a/api/src/exam-environment/routes/exam-environment.test.ts b/api/src/exam-environment/routes/exam-environment.test.ts index 3e6698cd6e6..d8f853e090c 100644 --- a/api/src/exam-environment/routes/exam-environment.test.ts +++ b/api/src/exam-environment/routes/exam-environment.test.ts @@ -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' }; }); diff --git a/api/src/routes/protected/user.test.ts b/api/src/routes/protected/user.test.ts index d69127a81ae..0ac38b9ddc4 100644 --- a/api/src/routes/protected/user.test.ts +++ b/api/src/routes/protected/user.test.ts @@ -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); + }); }); }); diff --git a/api/src/routes/protected/user.ts b/api/src/routes/protected/user.ts index 8541c70c97a..a1b80ab50e7 100644 --- a/api/src/routes/protected/user.ts +++ b/api/src/routes/protected/user.ts @@ -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: { diff --git a/api/src/schemas/user/exam-environment-token.ts b/api/src/schemas/user/exam-environment-token.ts index 2135c3086ef..907f67f0053 100644 --- a/api/src/schemas/user/exam-environment-token.ts +++ b/api/src/schemas/user/exam-environment-token.ts @@ -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 } }; diff --git a/api/src/utils/env.ts b/api/src/utils/env.ts index 6e20bc8afdb..736e59fd843 100644 --- a/api/src/utils/env.ts +++ b/api/src/utils/env.ts @@ -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' ? ''