feat(api): use jwt_access_token (in development) (#53997)

Co-authored-by: Tom <20648924+moT01@users.noreply.github.com>
This commit is contained in:
Oliver Eyton-Williams
2024-03-20 12:47:12 +01:00
committed by GitHub
parent bb2efe7618
commit aacfb281fb
25 changed files with 871 additions and 289 deletions

View File

@@ -0,0 +1,205 @@
import Fastify, { FastifyInstance } from 'fastify';
import jwt from 'jsonwebtoken';
import { COOKIE_DOMAIN, JWT_SECRET } from '../utils/env';
import { createAccessToken } from '../utils/tokens';
import cookies, { sign as signCookie } from './cookies';
import codeFlowAuth from './code-flow-auth';
describe('auth', () => {
let fastify: FastifyInstance;
beforeEach(async () => {
fastify = Fastify();
await fastify.register(cookies);
await fastify.register(codeFlowAuth);
});
afterEach(async () => {
await fastify.close();
});
describe('setAccessTokenCookie', () => {
// We won't need to keep doubly signing the cookie when we migrate the
// authentication, but for the MVP we have to be able to read the cookies
// set by the api-server. So, double signing:
it('should doubly sign the cookie', async () => {
const token = createAccessToken('test-id');
fastify.get('/test', async (req, reply) => {
reply.setAccessTokenCookie(token);
return { ok: true };
});
const singlySignedToken = jwt.sign({ accessToken: token }, JWT_SECRET);
const doublySignedToken = signCookie(singlySignedToken);
const res = await fastify.inject({
method: 'GET',
url: '/test'
});
expect(res.cookies[0]).toEqual(
expect.objectContaining({
name: 'jwt_access_token',
value: doublySignedToken,
path: '/',
sameSite: 'Lax',
domain: COOKIE_DOMAIN
})
);
});
// TODO: Post-MVP sync the cookie max-age with the token ttl (i.e. the
// max-age should be the ttl/1000, not ttl)
it('should set the max-age of the cookie to match the ttl of the token', async () => {
const token = createAccessToken('test-id', 123000);
fastify.get('/test', async (req, reply) => {
reply.setAccessTokenCookie(token);
return { ok: true };
});
const res = await fastify.inject({
method: 'GET',
url: '/test'
});
expect(res.cookies[0]).toEqual(
expect.objectContaining({
maxAge: 123000
})
);
});
});
describe('authorize', () => {
beforeEach(() => {
fastify.addHook('onRequest', fastify.authorize);
fastify.get('/test', () => {
return { message: 'ok' };
});
});
it('should reject if the access token is missing', async () => {
const res = await fastify.inject({
method: 'GET',
url: '/test'
});
expect(res.json()).toEqual({
type: 'info',
message: 'Access token is required for this request'
});
expect(res.statusCode).toBe(401);
});
it('should reject if the access token is not signed', async () => {
const token = jwt.sign(
{ accessToken: createAccessToken('123') },
JWT_SECRET
);
const res = await fastify.inject({
method: 'GET',
url: '/test',
cookies: {
jwt_access_token: token
}
});
expect(res.json()).toEqual({
type: 'info',
message: 'Access token is required for this request'
});
expect(res.statusCode).toBe(401);
});
it('should reject if the access token is invalid', async () => {
const token = jwt.sign(
{ accessToken: createAccessToken('123') },
'invalid-secret'
);
const res = await fastify.inject({
method: 'GET',
url: '/test',
cookies: {
jwt_access_token: signCookie(token)
}
});
expect(res.json()).toEqual({
type: 'info',
message: 'Your access token is invalid'
});
expect(res.statusCode).toBe(401);
});
it('should reject if the access token has expired', async () => {
const token = jwt.sign(
{ accessToken: createAccessToken('123', -1) },
JWT_SECRET
);
const res = await fastify.inject({
method: 'GET',
url: '/test',
cookies: {
jwt_access_token: signCookie(token)
}
});
expect(res.json()).toEqual({
type: 'info',
message: 'Access token is no longer valid'
});
expect(res.statusCode).toBe(401);
});
it('should reject if the user is not found', async () => {
// @ts-expect-error prisma isn't defined, since we're not building the
// full application here.
fastify.prisma = { user: { findUnique: () => null } };
const token = jwt.sign(
{ accessToken: createAccessToken('123') },
JWT_SECRET
);
const res = await fastify.inject({
method: 'GET',
url: '/test',
cookies: {
jwt_access_token: signCookie(token)
}
});
expect(res.json()).toEqual({
type: 'info',
message: 'Your access token is invalid'
});
});
it('should populate the request with the user if the token is valid', async () => {
const fakeUser = { id: '123', username: 'test-user' };
// @ts-expect-error prisma isn't defined, since we're not building the
// full application here.
fastify.prisma = { user: { findUnique: () => fakeUser } };
fastify.get('/test-user', req => {
expect(req.user).toEqual(fakeUser);
return { ok: true };
});
const token = jwt.sign(
{ accessToken: createAccessToken('123') },
JWT_SECRET
);
const res = await fastify.inject({
method: 'GET',
url: '/test-user',
cookies: {
jwt_access_token: signCookie(token)
}
});
expect(res.json()).toEqual({ ok: true });
});
});
});

View File

@@ -0,0 +1,86 @@
import { FastifyPluginCallback, FastifyReply, FastifyRequest } from 'fastify';
import fp from 'fastify-plugin';
import jwt from 'jsonwebtoken';
import { isBefore } from 'date-fns';
import { type user } from '@prisma/client';
import { COOKIE_DOMAIN, JWT_SECRET } from '../utils/env';
import { AccessToken } from '../utils/tokens';
declare module 'fastify' {
interface FastifyReply {
setAccessTokenCookie: (
this: FastifyReply,
accessToken: AccessToken
) => void;
}
interface FastifyRequest {
// TODO: is the full user the correct type here?
user?: user;
}
interface FastifyInstance {
authorize: (req: FastifyRequest, reply: FastifyReply) => void;
}
}
const codeFlowAuth: FastifyPluginCallback = (fastify, _options, done) => {
fastify.decorateReply(
'setAccessTokenCookie',
function (accessToken: AccessToken) {
const signedToken = jwt.sign({ accessToken }, JWT_SECRET);
void this.setCookie('jwt_access_token', signedToken, {
path: '/',
httpOnly: false,
secure: false,
sameSite: 'lax',
domain: COOKIE_DOMAIN,
signed: true,
maxAge: accessToken.ttl
});
}
);
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 send401 = (reply: FastifyReply, message: string) =>
reply.status(401).send({ type: 'info', message });
fastify.decorate(
'authorize',
async function (req: FastifyRequest, reply: FastifyReply) {
const tokenCookie = req.cookies.jwt_access_token;
if (!tokenCookie) return send401(reply, TOKEN_REQUIRED);
const unsignedToken = req.unsignCookie(tokenCookie);
if (!unsignedToken.valid) return send401(reply, TOKEN_REQUIRED);
const jwtAccessToken = unsignedToken.value;
try {
jwt.verify(jwtAccessToken!, JWT_SECRET);
} catch {
return send401(reply, TOKEN_INVALID);
}
const {
accessToken: { created, ttl, userId }
} = jwt.decode(jwtAccessToken!) as { accessToken: AccessToken };
const valid = isBefore(Date.now(), Date.parse(created) + ttl);
if (!valid) return send401(reply, TOKEN_EXPIRED);
const user = await fastify.prisma.user.findUnique({
where: { id: userId }
});
if (!user) return send401(reply, TOKEN_INVALID);
req.user = user;
}
);
done();
};
export default fp(codeFlowAuth);

View File

@@ -0,0 +1,70 @@
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { COOKIE_SECRET } from '../utils/env';
import cookies from './cookies';
describe('cookies', () => {
let fastify: FastifyInstance;
beforeEach(async () => {
fastify = Fastify();
await fastify.register(cookies);
});
afterEach(async () => {
await fastify.close();
});
it('should prefix signed cookies with "s:" (url-encoded)', async () => {
fastify.get('/test', async (req, reply) => {
void reply.setCookie('test', 'value', { signed: true });
return { ok: true };
});
const res = await fastify.inject({
method: 'GET',
url: '/test'
});
expect(res.headers['set-cookie']).toMatch(/test=s%3Avalue\.\w*/);
});
it('should be able to unsign cookies', async () => {
const signedCookie = `test=s%3A${fastifyCookie.sign('value', COOKIE_SECRET)}`;
fastify.get('/test', (req, reply) => {
void reply.send({ unsigned: req.unsignCookie(req.cookies.test!) });
});
const res = await fastify.inject({
method: 'GET',
url: '/test',
headers: {
cookie: signedCookie
}
});
expect(res.json()).toEqual({
unsigned: { value: 'value', renew: false, valid: true }
});
});
it('should reject cookies not prefixed with "s:"', async () => {
const signedCookie = `test=${fastifyCookie.sign('value', COOKIE_SECRET)}`;
fastify.get('/test', (req, reply) => {
void reply.send({ unsigned: req.unsignCookie(req.cookies.test!) });
});
const res = await fastify.inject({
method: 'GET',
url: '/test',
headers: {
cookie: signedCookie
}
});
expect(res.json()).toEqual({
unsigned: { value: null, renew: false, valid: false }
});
});
});

View File

@@ -0,0 +1,50 @@
import fastifyCookie from '@fastify/cookie';
import { FastifyPluginCallback } from 'fastify';
import fp from 'fastify-plugin';
import { COOKIE_SECRET } from '../utils/env';
/**
* Signs a cookie value by prefixing it with "s:" and using the COOKIE_SECRET.
*
* @param value The value to sign.
* @returns The signed cookie value.
*/
export const sign = (value: string) =>
's:' + fastifyCookie.sign(value, COOKIE_SECRET);
/**
* Unsigns a cookie value by removing the "s:" prefix and using the COOKIE_SECRET.
*
* @param rawValue The signed cookie value.
* @returns The unsigned cookie value.
*/
export const unsign = (rawValue: string) => {
const prefix = rawValue.slice(0, 2);
if (prefix !== 's:') return { valid: false, renew: false, value: null };
const value = rawValue.slice(2);
return fastifyCookie.unsign(value, COOKIE_SECRET);
};
/**
* Compatibility plugin for using cookies signed by express. By prefixing with
* "s:" and removing it when unsigning, we can continue to use the same cookies
* in Fastify.
*
* @param fastify The Fastify instance.
* @param _options Options passed to the plugin via `fastify.register(plugin, options)`.
* @param done Callback to signal that the logic has completed.
*/
const cookies: FastifyPluginCallback = (fastify, _options, done) => {
void fastify.register(fastifyCookie, {
secret: {
sign,
unsign
}
});
done();
};
export default fp(cookies);

View File

@@ -2,15 +2,7 @@ import { FastifyPluginCallback } from 'fastify';
import fp from 'fastify-plugin';
import { HOME_LOCATION } from '../utils/env';
const allowedOrigins = [
'https://www.freecodecamp.dev',
'https://www.freecodecamp.org',
'https://beta.freecodecamp.dev',
'https://beta.freecodecamp.org',
'https://chinese.freecodecamp.dev',
'https://chinese.freecodecamp.org'
];
import { allowedOrigins } from '../utils/allowed-origins';
const cors: FastifyPluginCallback = (fastify, _options, done) => {
fastify.options('*', (_req, reply) => {