mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-06-03 01:01:13 -04:00
feat(api): use jwt_access_token (in development) (#53997)
Co-authored-by: Tom <20648924+moT01@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
bb2efe7618
commit
aacfb281fb
205
api/src/plugins/code-flow-auth.test.ts
Normal file
205
api/src/plugins/code-flow-auth.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
86
api/src/plugins/code-flow-auth.ts
Normal file
86
api/src/plugins/code-flow-auth.ts
Normal 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);
|
||||
70
api/src/plugins/cookies.test.ts
Normal file
70
api/src/plugins/cookies.test.ts
Normal 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 }
|
||||
});
|
||||
});
|
||||
});
|
||||
50
api/src/plugins/cookies.ts
Normal file
50
api/src/plugins/cookies.ts
Normal 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);
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user