mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
fix(api): modularize auth handlers (#55671)
This commit is contained in:
committed by
GitHub
parent
7d84da184a
commit
e9ac6c5e72
+36
-33
@@ -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';
|
||||
@@ -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);
|
||||
|
||||
void fastify.register(challengeRoutes);
|
||||
void fastify.register(donateRoutes);
|
||||
void fastify.register(protectedCertificateRoutes);
|
||||
void fastify.register(settingRoutes);
|
||||
void fastify.register(userRoutes);
|
||||
done();
|
||||
await fastify.register(challengeRoutes);
|
||||
await fastify.register(donateRoutes);
|
||||
await fastify.register(protectedCertificateRoutes);
|
||||
await fastify.register(settingRoutes);
|
||||
await fastify.register(userRoutes);
|
||||
});
|
||||
|
||||
// CSRF protection disabled:
|
||||
await fastify.register(async function (fastify, _opts) {
|
||||
fastify.addHook('onRequest', fastify.send401IfNoUser);
|
||||
|
||||
await fastify.register(userGetRoutes);
|
||||
});
|
||||
|
||||
// Routes that redirect if access is denied:
|
||||
await fastify.register(async function (fastify, _opts) {
|
||||
fastify.addHook('onRequest', fastify.redirectIfNoUser);
|
||||
|
||||
await fastify.register(settingRedirectRoutes);
|
||||
});
|
||||
});
|
||||
|
||||
// Routes requiring authentication and NOT CSRF protection
|
||||
void fastify.register(function (fastify, _opts, done) {
|
||||
fastify.addHook('onRequest', fastify.authorize);
|
||||
|
||||
void fastify.register(userGetRoutes);
|
||||
done();
|
||||
});
|
||||
|
||||
// Routes requiring authentication that redirect on failure
|
||||
void fastify.register(function (fastify, _opts, done) {
|
||||
fastify.addHook('onRequest', fastify.authorizeOrRedirect);
|
||||
|
||||
void fastify.register(settingRedirectRoutes);
|
||||
done();
|
||||
});
|
||||
|
||||
// Routes not requiring authentication
|
||||
// Routes not requiring authentication:
|
||||
void fastify.register(mobileAuth0Routes);
|
||||
// TODO: consolidate with LOCAL_MOCK_AUTH
|
||||
if (FCC_ENABLE_DEV_LOGIN_MODE) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { FastifyPluginCallback, FastifyRequest } 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';
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const auth: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
fastify.decorateReply('setAccessTokenCookie', function (accessToken: Token) {
|
||||
const signedToken = jwt.sign({ accessToken }, JWT_SECRET);
|
||||
void this.setCookie('jwt_access_token', signedToken, {
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
maxAge: accessToken.ttl
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
const user = await fastify.prisma.user.findUnique({
|
||||
where: { id: accessToken.userId }
|
||||
});
|
||||
if (!user) return setAccessDenied(req, TOKEN_INVALID);
|
||||
req.user = user;
|
||||
};
|
||||
|
||||
fastify.decorate('authorize', handleAuth);
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
export default fp(auth, { name: 'auth', dependencies: ['cookies'] });
|
||||
@@ -7,7 +7,7 @@ import prismaPlugin from '../db/prisma';
|
||||
import cookies, { sign, unsign } from './cookies';
|
||||
import { auth0Client } from './auth0';
|
||||
import redirectWithMessage, { formatMessage } from './redirect-with-message';
|
||||
import codeFlowAuth from './code-flow-auth';
|
||||
import auth from './auth';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
jest.mock('../utils/env', () => ({
|
||||
@@ -23,7 +23,7 @@ describe('auth0 plugin', () => {
|
||||
|
||||
await fastify.register(cookies);
|
||||
await fastify.register(redirectWithMessage);
|
||||
await fastify.register(codeFlowAuth);
|
||||
await fastify.register(auth);
|
||||
await fastify.register(auth0Client);
|
||||
await fastify.register(prismaPlugin);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
|
||||
import { HOME_LOCATION } from '../utils/env';
|
||||
import bouncer from './bouncer';
|
||||
import auth from './auth';
|
||||
import cookies from './cookies';
|
||||
import redirectWithMessage, { formatMessage } from './redirect-with-message';
|
||||
|
||||
let authorizeSpy: jest.SpyInstance;
|
||||
|
||||
async function setupServer() {
|
||||
const fastify = Fastify();
|
||||
await fastify.register(cookies);
|
||||
await fastify.register(auth);
|
||||
authorizeSpy = jest.spyOn(fastify, 'authorize');
|
||||
|
||||
await fastify.register(redirectWithMessage);
|
||||
await fastify.register(bouncer);
|
||||
fastify.addHook('onRequest', fastify.authorize);
|
||||
fastify.get('/', (_req, reply) => {
|
||||
void reply.send({ foo: 'bar' });
|
||||
});
|
||||
return fastify;
|
||||
}
|
||||
|
||||
describe('bouncer', () => {
|
||||
let fastify: FastifyInstance;
|
||||
beforeEach(async () => {
|
||||
fastify = await setupServer();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fastify.close();
|
||||
});
|
||||
|
||||
describe('send401IfNoUser', () => {
|
||||
beforeEach(() => {
|
||||
fastify.addHook('onRequest', fastify.send401IfNoUser);
|
||||
});
|
||||
|
||||
it('should return 401 if no user is present', async () => {
|
||||
const message = {
|
||||
type: 'danger',
|
||||
content: 'Something undesirable occurred'
|
||||
};
|
||||
authorizeSpy.mockImplementationOnce((req, _reply, done) => {
|
||||
req.accessDeniedMessage = message;
|
||||
done();
|
||||
});
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/'
|
||||
});
|
||||
|
||||
expect(res.json()).toStrictEqual({
|
||||
type: message.type,
|
||||
message: message.content
|
||||
});
|
||||
expect(res.statusCode).toEqual(401);
|
||||
});
|
||||
|
||||
it('should not alter the response if a user is present', async () => {
|
||||
authorizeSpy.mockImplementationOnce((req, _reply, done) => {
|
||||
req.user = { id: '123' };
|
||||
done();
|
||||
});
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/'
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({ foo: 'bar' });
|
||||
expect(res.statusCode).toEqual(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('redirectIfNoUser', () => {
|
||||
beforeEach(() => {
|
||||
fastify.addHook('onRequest', fastify.redirectIfNoUser);
|
||||
});
|
||||
const redirectLocation = `${HOME_LOCATION}?${formatMessage({ type: 'info', content: 'Only authenticated users can access this route. Please sign in and try again.' })}`;
|
||||
|
||||
it('should redirect to HOME_LOCATION if no user is present', async () => {
|
||||
const message = {
|
||||
type: 'danger',
|
||||
content: 'At the moment, content is ignored'
|
||||
};
|
||||
authorizeSpy.mockImplementationOnce((req, _reply, done) => {
|
||||
req.accessDeniedMessage = message;
|
||||
done();
|
||||
});
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/'
|
||||
});
|
||||
|
||||
expect(res.headers.location).toBe(redirectLocation);
|
||||
expect(res.statusCode).toEqual(302);
|
||||
});
|
||||
|
||||
it('should not alter the response if a user is present', async () => {
|
||||
authorizeSpy.mockImplementationOnce((req, _reply, done) => {
|
||||
req.user = { id: '123' };
|
||||
done();
|
||||
});
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/'
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({ foo: 'bar' });
|
||||
expect(res.statusCode).toEqual(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fallback hook', () => {
|
||||
it('should reject unauthed requests when no other reject hooks are added', async () => {
|
||||
const message = {
|
||||
type: 'danger',
|
||||
content: 'Something undesirable occurred'
|
||||
};
|
||||
authorizeSpy.mockImplementationOnce((req, _reply, done) => {
|
||||
req.accessDeniedMessage = message;
|
||||
done();
|
||||
});
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/'
|
||||
});
|
||||
|
||||
expect(res.json()).toStrictEqual({
|
||||
type: message.type,
|
||||
message: message.content
|
||||
});
|
||||
});
|
||||
|
||||
it('should not be called if another reject hook is added', async () => {
|
||||
const redirectLocation = `${HOME_LOCATION}?${formatMessage({ type: 'info', content: 'Only authenticated users can access this route. Please sign in and try again.' })}`;
|
||||
const message = {
|
||||
type: 'danger',
|
||||
content: 'Something undesirable occurred'
|
||||
};
|
||||
// using redirectIfNoUser as the reject hook since then it's obvious that
|
||||
// the fallback hook is not called.
|
||||
fastify.addHook('onRequest', fastify.redirectIfNoUser);
|
||||
authorizeSpy.mockImplementationOnce((req, _reply, done) => {
|
||||
req.accessDeniedMessage = message;
|
||||
done();
|
||||
});
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/'
|
||||
});
|
||||
|
||||
expect(res.headers.location).toBe(redirectLocation);
|
||||
expect(res.statusCode).toEqual(302);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type {
|
||||
FastifyPluginCallback,
|
||||
FastifyRequest,
|
||||
FastifyReply
|
||||
} from 'fastify';
|
||||
import fp from 'fastify-plugin';
|
||||
import { getRedirectParams } from '../utils/redirection';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
send401IfNoUser: (req: FastifyRequest, reply: FastifyReply) => void;
|
||||
redirectIfNoUser: (req: FastifyRequest, reply: FastifyReply) => void;
|
||||
}
|
||||
}
|
||||
|
||||
const plugin: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
fastify.decorate(
|
||||
'send401IfNoUser',
|
||||
async function (req: FastifyRequest, reply: FastifyReply) {
|
||||
if (!req.user) {
|
||||
await reply.status(401).send({
|
||||
type: req.accessDeniedMessage?.type,
|
||||
message: req.accessDeniedMessage?.content
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
fastify.decorate(
|
||||
'redirectIfNoUser',
|
||||
async function (req: FastifyRequest, reply: FastifyReply) {
|
||||
if (!req.user) {
|
||||
const { origin } = getRedirectParams(req);
|
||||
await reply.redirectWithMessage(origin, {
|
||||
type: 'info',
|
||||
content:
|
||||
'Only authenticated users can access this route. Please sign in and try again.'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
fastify.addHook('preParsing', fastify.send401IfNoUser);
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
export default fp(plugin, { dependencies: ['auth', 'redirect-with-message'] });
|
||||
@@ -1,328 +0,0 @@
|
||||
import Fastify, { FastifyInstance } from 'fastify';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
import { COOKIE_DOMAIN, HOME_LOCATION, JWT_SECRET } from '../utils/env';
|
||||
import { type Token, createAccessToken } from '../utils/tokens';
|
||||
import cookies, { sign as signCookie, unsign as unsignCookie } from './cookies';
|
||||
import codeFlowAuth from './code-flow-auth';
|
||||
import redirectWithMessage, { formatMessage } from './redirect-with-message';
|
||||
|
||||
describe('auth', () => {
|
||||
let fastify: FastifyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
fastify = Fastify();
|
||||
await fastify.register(cookies);
|
||||
await fastify.register(redirectWithMessage);
|
||||
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 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.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 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('authorizeOrRedirect', () => {
|
||||
const redirectLocation = `${HOME_LOCATION}?${formatMessage({ type: 'info', content: 'Only authenticated users can access this route. Please sign in and try again.' })}`;
|
||||
|
||||
beforeEach(() => {
|
||||
fastify.addHook('onRequest', fastify.authorizeOrRedirect);
|
||||
fastify.get('/test', () => {
|
||||
return { message: 'ok' };
|
||||
});
|
||||
});
|
||||
|
||||
it('should redirect to the origin if the access token is missing', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test'
|
||||
});
|
||||
|
||||
expect(res.headers.location).toBe(redirectLocation);
|
||||
expect(res.statusCode).toBe(302);
|
||||
});
|
||||
|
||||
it('should redirect to the origin 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.headers.location).toBe(redirectLocation);
|
||||
expect(res.statusCode).toBe(302);
|
||||
});
|
||||
|
||||
it('should redirect to the origin 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.headers.location).toBe(redirectLocation);
|
||||
expect(res.statusCode).toBe(302);
|
||||
});
|
||||
|
||||
it('should redirect to the origin 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.headers.location).toBe(redirectLocation);
|
||||
expect(res.statusCode).toBe(302);
|
||||
});
|
||||
|
||||
it('should redirect to the origin 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.headers.location).toBe(redirectLocation);
|
||||
expect(res.statusCode).toBe(302);
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
import { FastifyPluginCallback, FastifyReply, FastifyRequest } 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 { getRedirectParams } from '../utils/redirection';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyReply {
|
||||
setAccessTokenCookie: (this: FastifyReply, accessToken: Token) => void;
|
||||
}
|
||||
|
||||
interface FastifyRequest {
|
||||
// TODO: is the full user the correct type here?
|
||||
user?: user;
|
||||
}
|
||||
|
||||
interface FastifyInstance {
|
||||
authorize: (req: FastifyRequest, reply: FastifyReply) => void;
|
||||
authorizeOrRedirect: (req: FastifyRequest, reply: FastifyReply) => void;
|
||||
}
|
||||
}
|
||||
|
||||
const codeFlowAuth: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
fastify.decorateReply('setAccessTokenCookie', function (accessToken: Token) {
|
||||
const signedToken = jwt.sign({ accessToken }, JWT_SECRET);
|
||||
void this.setCookie('jwt_access_token', signedToken, {
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
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 = (
|
||||
_req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
message: string
|
||||
): void => {
|
||||
void reply.status(401).send({ type: 'info', message });
|
||||
};
|
||||
|
||||
const redirectHome = (
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
_ignored: string
|
||||
) => {
|
||||
const { origin } = getRedirectParams(req);
|
||||
|
||||
void reply.redirectWithMessage(origin, {
|
||||
type: 'info',
|
||||
content:
|
||||
'Only authenticated users can access this route. Please sign in and try again.'
|
||||
});
|
||||
};
|
||||
|
||||
const handleAuth =
|
||||
(
|
||||
rejectStrategy: (
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
message: string
|
||||
) => void
|
||||
) =>
|
||||
async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
const tokenCookie = req.cookies.jwt_access_token;
|
||||
if (!tokenCookie) return rejectStrategy(req, reply, TOKEN_REQUIRED);
|
||||
|
||||
const unsignedToken = req.unsignCookie(tokenCookie);
|
||||
if (!unsignedToken.valid)
|
||||
return rejectStrategy(req, reply, TOKEN_REQUIRED);
|
||||
|
||||
const jwtAccessToken = unsignedToken.value;
|
||||
|
||||
try {
|
||||
jwt.verify(jwtAccessToken, JWT_SECRET);
|
||||
} catch {
|
||||
return rejectStrategy(req, reply, TOKEN_INVALID);
|
||||
}
|
||||
|
||||
const { accessToken } = jwt.decode(jwtAccessToken) as {
|
||||
accessToken: Token;
|
||||
};
|
||||
|
||||
if (isExpired(accessToken))
|
||||
return rejectStrategy(req, reply, TOKEN_EXPIRED);
|
||||
|
||||
const user = await fastify.prisma.user.findUnique({
|
||||
where: { id: accessToken.userId }
|
||||
});
|
||||
if (!user) return rejectStrategy(req, reply, TOKEN_INVALID);
|
||||
req.user = user;
|
||||
};
|
||||
|
||||
fastify.decorate('authorize', handleAuth(send401));
|
||||
fastify.decorate('authorizeOrRedirect', handleAuth(redirectHome));
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
export default fp(codeFlowAuth, { dependencies: ['redirect-with-message'] });
|
||||
@@ -75,4 +75,4 @@ const cookies: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
done();
|
||||
};
|
||||
|
||||
export default fp(cookies);
|
||||
export default fp(cookies, { name: 'cookies' });
|
||||
|
||||
Reference in New Issue
Block a user