Skip to content

Commit

Permalink
fix(api): modularize auth handlers (freeCodeCamp#55671)
Browse files Browse the repository at this point in the history
  • Loading branch information
ojeytonwilliams authored Aug 8, 2024
1 parent 7d84da1 commit e9ac6c5
Show file tree
Hide file tree
Showing 9 changed files with 578 additions and 467 deletions.
63 changes: 33 additions & 30 deletions api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ import { SESProvider } from './plugins/mail-providers/ses';
import mailer from './plugins/mailer';
import redirectWithMessage from './plugins/redirect-with-message';
import security from './plugins/security';
import codeFlowAuth from './plugins/code-flow-auth';
import auth from './plugins/auth';
import bouncer from './plugins/bouncer';
import notFound from './plugins/not-found';
import { authRoutes, mobileAuth0Routes } from './routes/auth';
import { devAuthRoutes } from './routes/auth-dev';
Expand Down Expand Up @@ -182,44 +183,46 @@ export const build = async (

// redirectWithMessage must be registered before codeFlowAuth
void fastify.register(redirectWithMessage);
void fastify.register(codeFlowAuth);
void fastify.register(auth);
void fastify.register(notFound);
void fastify.register(prismaPlugin);

// Routes requiring authentication and CSRF protection
void fastify.register(function (fastify, _opts, done) {
// The order matters here, since we want to reject invalid cross site requests
// before checking if the user is authenticated.
// @ts-expect-error - @fastify/csrf-protection needs to update their types
// eslint-disable-next-line @typescript-eslint/unbound-method
fastify.addHook('onRequest', fastify.csrfProtection);
// Routes requiring authentication:
void fastify.register(async function (fastify, _opts) {
await fastify.register(bouncer);
fastify.addHook('onRequest', fastify.authorize);
// CSRF protection enabled:
await fastify.register(async function (fastify, _opts) {
// TODO: bounce unauthed requests before checking CSRF token. This will
// mean moving csrfProtection into custom plugin and testing separately,
// because it's a pain to mess around with other cookies/hook order.
// @ts-expect-error - @fastify/csrf-protection needs to update their types
// eslint-disable-next-line @typescript-eslint/unbound-method
fastify.addHook('onRequest', fastify.csrfProtection);
fastify.addHook('onRequest', fastify.send401IfNoUser);

await fastify.register(challengeRoutes);
await fastify.register(donateRoutes);
await fastify.register(protectedCertificateRoutes);
await fastify.register(settingRoutes);
await fastify.register(userRoutes);
});

void fastify.register(challengeRoutes);
void fastify.register(donateRoutes);
void fastify.register(protectedCertificateRoutes);
void fastify.register(settingRoutes);
void fastify.register(userRoutes);
done();
});

// Routes requiring authentication and NOT CSRF protection
void fastify.register(function (fastify, _opts, done) {
fastify.addHook('onRequest', fastify.authorize);
// CSRF protection disabled:
await fastify.register(async function (fastify, _opts) {
fastify.addHook('onRequest', fastify.send401IfNoUser);

void fastify.register(userGetRoutes);
done();
});
await fastify.register(userGetRoutes);
});

// Routes requiring authentication that redirect on failure
void fastify.register(function (fastify, _opts, done) {
fastify.addHook('onRequest', fastify.authorizeOrRedirect);
// Routes that redirect if access is denied:
await fastify.register(async function (fastify, _opts) {
fastify.addHook('onRequest', fastify.redirectIfNoUser);

void fastify.register(settingRedirectRoutes);
done();
await fastify.register(settingRedirectRoutes);
});
});

// Routes not requiring authentication
// Routes not requiring authentication:
void fastify.register(mobileAuth0Routes);
// TODO: consolidate with LOCAL_MOCK_AUTH
if (FCC_ENABLE_DEV_LOGIN_MODE) {
Expand Down
253 changes: 253 additions & 0 deletions api/src/plugins/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
import Fastify, { FastifyInstance } from 'fastify';
import jwt from 'jsonwebtoken';

import { COOKIE_DOMAIN, JWT_SECRET } from '../utils/env';
import { type Token, createAccessToken } from '../utils/tokens';
import cookies, { sign as signCookie, unsign as unsignCookie } from './cookies';
import auth from './auth';

async function setupServer() {
const fastify = Fastify();
await fastify.register(cookies);
await fastify.register(auth);
return fastify;
}

describe('auth', () => {
let fastify: FastifyInstance;

beforeEach(async () => {
fastify = await setupServer();
});

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 res = await fastify.inject({
method: 'GET',
url: '/test'
});

const { value, ...rest } = res.cookies[0]!;
const unsignedOnce = unsignCookie(value);
const unsignedTwice = jwt.verify(unsignedOnce.value!, JWT_SECRET) as {
accessToken: Token;
};
expect(unsignedTwice.accessToken).toEqual(token);
expect(rest).toEqual({
name: 'jwt_access_token',
path: '/',
sameSite: 'Lax',
domain: COOKIE_DOMAIN,
maxAge: token.ttl
});
});

// 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.get('/test', (_req, reply) => {
void reply.send({ ok: true });
});
fastify.addHook('onRequest', fastify.authorize);
});

it('should deny if the access token is missing', async () => {
expect.assertions(4);

fastify.addHook('onRequest', (req, _reply, done) => {
expect(req.accessDeniedMessage).toEqual({
type: 'info',
content: 'Access token is required for this request'
});
expect(req.user).toBeNull();
done();
});

const res = await fastify.inject({
method: 'GET',
url: '/test'
});

expect(res.json()).toEqual({ ok: true });
expect(res.statusCode).toEqual(200);
});

it('should deny if the access token is not signed', async () => {
expect.assertions(4);

fastify.addHook('onRequest', (req, _reply, done) => {
expect(req.accessDeniedMessage).toEqual({
type: 'info',
content: 'Access token is required for this request'
});
expect(req.user).toBeNull();
done();
});

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({ ok: true });
expect(res.statusCode).toEqual(200);
});

it('should deny if the access token is invalid', async () => {
expect.assertions(4);

fastify.addHook('onRequest', (req, _reply, done) => {
expect(req.accessDeniedMessage).toEqual({
type: 'info',
content: 'Your access token is invalid'
});
expect(req.user).toBeNull();
done();
});

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({ ok: true });
expect(res.statusCode).toEqual(200);
});

it('should deny if the access token has expired', async () => {
expect.assertions(4);

fastify.addHook('onRequest', (req, _reply, done) => {
expect(req.accessDeniedMessage).toEqual({
type: 'info',
content: 'Access token is no longer valid'
});
expect(req.user).toBeNull();
done();
});

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({ ok: true });
expect(res.statusCode).toEqual(200);
});

it('should deny if the user is not found', async () => {
expect.assertions(4);

fastify.addHook('onRequest', (req, _reply, done) => {
expect(req.accessDeniedMessage).toEqual({
type: 'info',
content: 'Your access token is invalid'
});
expect(req.user).toBeNull();
done();
});

// @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({ ok: true });
expect(res.statusCode).toEqual(200);
});

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 });
expect(res.statusCode).toEqual(200);
});
});
});
Loading

0 comments on commit e9ac6c5

Please sign in to comment.