mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-02-28 23:02:24 -05:00
httpOnly (invisible to JS) and secure (https only) are now used. In order to update existing users without requiring them to re-authenticate, each request sets those properties on the cookie. Finally, the maxAge is now 30 days and is also updated on each request. i.e. it's a rolling 30 days. Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
193 lines
5.8 KiB
TypeScript
193 lines
5.8 KiB
TypeScript
import { FastifyPluginCallback, FastifyRequest, FastifyReply } from 'fastify';
|
|
import fp from 'fastify-plugin';
|
|
import jwt from 'jsonwebtoken';
|
|
import { type user } from '@prisma/client';
|
|
|
|
import { JWT_SECRET } from '../utils/env';
|
|
import { type Token, isExpired } from '../utils/tokens';
|
|
import { ERRORS } from '../exam-environment/utils/errors';
|
|
|
|
declare module 'fastify' {
|
|
interface FastifyReply {
|
|
setAccessTokenCookie: (this: FastifyReply, accessToken: Token) => void;
|
|
}
|
|
|
|
interface FastifyRequest {
|
|
// TODO: is the full user the correct type here?
|
|
user: user | null;
|
|
accessDeniedMessage: { type: 'info'; content: string } | null;
|
|
}
|
|
|
|
interface FastifyInstance {
|
|
authorize: (req: FastifyRequest, reply: FastifyReply) => void;
|
|
authorizeExamEnvironmentToken: (
|
|
req: FastifyRequest,
|
|
reply: FastifyReply
|
|
) => void;
|
|
}
|
|
}
|
|
|
|
const auth: FastifyPluginCallback = (fastify, _options, done) => {
|
|
const cookieOpts = {
|
|
httpOnly: true,
|
|
secure: true,
|
|
maxAge: 2592000 // thirty days in seconds
|
|
};
|
|
fastify.decorateReply('setAccessTokenCookie', function (accessToken: Token) {
|
|
const signedToken = jwt.sign({ accessToken }, JWT_SECRET);
|
|
void this.setCookie('jwt_access_token', signedToken, cookieOpts);
|
|
});
|
|
|
|
// update existing jwt_access_token cookie properties
|
|
fastify.addHook('onRequest', (req, reply, done) => {
|
|
const rawCookie = req.cookies['jwt_access_token'];
|
|
if (rawCookie) {
|
|
const jwtAccessToken = req.unsignCookie(rawCookie);
|
|
if (jwtAccessToken.valid) {
|
|
reply.setCookie('jwt_access_token', jwtAccessToken.value, cookieOpts);
|
|
}
|
|
}
|
|
done();
|
|
});
|
|
|
|
fastify.decorateRequest('accessDeniedMessage', null);
|
|
fastify.decorateRequest('user', null);
|
|
|
|
const TOKEN_REQUIRED = 'Access token is required for this request';
|
|
const TOKEN_INVALID = 'Your access token is invalid';
|
|
const TOKEN_EXPIRED = 'Access token is no longer valid';
|
|
|
|
const setAccessDenied = (req: FastifyRequest, content: string) =>
|
|
(req.accessDeniedMessage = { type: 'info', content });
|
|
|
|
const handleAuth = async (req: FastifyRequest) => {
|
|
const tokenCookie = req.cookies.jwt_access_token;
|
|
if (!tokenCookie) return setAccessDenied(req, TOKEN_REQUIRED);
|
|
|
|
const unsignedToken = req.unsignCookie(tokenCookie);
|
|
if (!unsignedToken.valid) return setAccessDenied(req, TOKEN_REQUIRED);
|
|
|
|
const jwtAccessToken = unsignedToken.value;
|
|
|
|
try {
|
|
jwt.verify(jwtAccessToken, JWT_SECRET);
|
|
} catch {
|
|
return setAccessDenied(req, TOKEN_INVALID);
|
|
}
|
|
|
|
const { accessToken } = jwt.decode(jwtAccessToken) as {
|
|
accessToken: Token;
|
|
};
|
|
|
|
if (isExpired(accessToken)) return setAccessDenied(req, TOKEN_EXPIRED);
|
|
// We're using token.userId since it's possible for the user record to be
|
|
// malformed and for prisma to throw while trying to find the user.
|
|
fastify.Sentry?.setUser({
|
|
id: accessToken.userId
|
|
});
|
|
|
|
const user = await fastify.prisma.user.findUnique({
|
|
where: { id: accessToken.userId }
|
|
});
|
|
if (!user) return setAccessDenied(req, TOKEN_INVALID);
|
|
req.user = user;
|
|
};
|
|
|
|
async function handleExamEnvironmentTokenAuth(
|
|
req: FastifyRequest,
|
|
reply: FastifyReply
|
|
) {
|
|
const { 'exam-environment-authorization-token': encodedToken } =
|
|
req.headers;
|
|
|
|
if (!encodedToken || typeof encodedToken !== 'string') {
|
|
void reply.code(400);
|
|
return reply.send(
|
|
ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_AUTHORIZATION_TOKEN(
|
|
'EXAM-ENVIRONMENT-AUTHORIZATION-TOKEN header is a required string.'
|
|
)
|
|
);
|
|
}
|
|
|
|
try {
|
|
jwt.verify(encodedToken, JWT_SECRET);
|
|
} catch (e) {
|
|
void reply.code(403);
|
|
return reply.send(
|
|
ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_AUTHORIZATION_TOKEN(
|
|
JSON.stringify(e)
|
|
)
|
|
);
|
|
}
|
|
|
|
const payload = jwt.decode(encodedToken);
|
|
|
|
if (typeof payload !== 'object' || payload === null) {
|
|
void reply.code(500);
|
|
return reply.send(
|
|
ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_AUTHORIZATION_TOKEN(
|
|
'Unreachable. Decoded token has been verified.'
|
|
)
|
|
);
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
const examEnvironmentAuthorizationToken =
|
|
payload['examEnvironmentAuthorizationToken'];
|
|
|
|
// if (typeof examEnvironmentAuthorizationToken !== 'string') {
|
|
// // TODO: This code is debatable, because the token would have to have been signed by the api
|
|
// // which means it is valid, but, somehow, got signed as an object instead of a string.
|
|
// void reply.code(400+500);
|
|
// return reply.send(
|
|
// ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_AUTHORIZATION_TOKEN(
|
|
// 'EXAM-ENVIRONMENT-AUTHORIZATION-TOKEN is not valid.'
|
|
// )
|
|
// );
|
|
// }
|
|
|
|
assertIsString(examEnvironmentAuthorizationToken);
|
|
|
|
const token =
|
|
await fastify.prisma.examEnvironmentAuthorizationToken.findFirst({
|
|
where: {
|
|
id: examEnvironmentAuthorizationToken
|
|
}
|
|
});
|
|
|
|
if (!token) {
|
|
return {
|
|
message: 'Token not found'
|
|
};
|
|
}
|
|
// We're using token.userId since it's possible for the user record to be
|
|
// malformed and for prisma to throw while trying to find the user.
|
|
|
|
fastify.Sentry?.setUser({
|
|
id: token.userId
|
|
});
|
|
|
|
const user = await fastify.prisma.user.findUnique({
|
|
where: { id: token.userId }
|
|
});
|
|
if (!user) return setAccessDenied(req, TOKEN_INVALID);
|
|
req.user = user;
|
|
}
|
|
|
|
fastify.decorate('authorize', handleAuth);
|
|
fastify.decorate(
|
|
'authorizeExamEnvironmentToken',
|
|
handleExamEnvironmentTokenAuth
|
|
);
|
|
|
|
done();
|
|
};
|
|
|
|
function assertIsString(some: unknown): asserts some is string {
|
|
if (typeof some !== 'string') {
|
|
throw new Error('Expected a string');
|
|
}
|
|
}
|
|
|
|
export default fp(auth, { name: 'auth', dependencies: ['cookies'] });
|