fix(api): modularize auth handlers (#55671)

This commit is contained in:
Oliver Eyton-Williams
2024-08-08 19:35:25 +02:00
committed by GitHub
parent 7d84da184a
commit e9ac6c5e72
9 changed files with 581 additions and 470 deletions
+36 -33
View File
@@ -24,7 +24,8 @@ import { SESProvider } from './plugins/mail-providers/ses';
import mailer from './plugins/mailer'; import mailer from './plugins/mailer';
import redirectWithMessage from './plugins/redirect-with-message'; import redirectWithMessage from './plugins/redirect-with-message';
import security from './plugins/security'; 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 notFound from './plugins/not-found';
import { authRoutes, mobileAuth0Routes } from './routes/auth'; import { authRoutes, mobileAuth0Routes } from './routes/auth';
import { devAuthRoutes } from './routes/auth-dev'; import { devAuthRoutes } from './routes/auth-dev';
@@ -182,44 +183,46 @@ export const build = async (
// redirectWithMessage must be registered before codeFlowAuth // redirectWithMessage must be registered before codeFlowAuth
void fastify.register(redirectWithMessage); void fastify.register(redirectWithMessage);
void fastify.register(codeFlowAuth); void fastify.register(auth);
void fastify.register(notFound); void fastify.register(notFound);
void fastify.register(prismaPlugin); void fastify.register(prismaPlugin);
// Routes requiring authentication and CSRF protection // Routes requiring authentication:
void fastify.register(function (fastify, _opts, done) { void fastify.register(async function (fastify, _opts) {
// The order matters here, since we want to reject invalid cross site requests await fastify.register(bouncer);
// 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);
fastify.addHook('onRequest', fastify.authorize); 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); await fastify.register(challengeRoutes);
void fastify.register(donateRoutes); await fastify.register(donateRoutes);
void fastify.register(protectedCertificateRoutes); await fastify.register(protectedCertificateRoutes);
void fastify.register(settingRoutes); await fastify.register(settingRoutes);
void fastify.register(userRoutes); await fastify.register(userRoutes);
done(); });
// 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 not requiring authentication:
// 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
void fastify.register(mobileAuth0Routes); void fastify.register(mobileAuth0Routes);
// TODO: consolidate with LOCAL_MOCK_AUTH // TODO: consolidate with LOCAL_MOCK_AUTH
if (FCC_ENABLE_DEV_LOGIN_MODE) { if (FCC_ENABLE_DEV_LOGIN_MODE) {
+253
View File
@@ -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);
});
});
});
+78
View File
@@ -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'] });
+2 -2
View File
@@ -7,7 +7,7 @@ import prismaPlugin from '../db/prisma';
import cookies, { sign, unsign } from './cookies'; import cookies, { sign, unsign } from './cookies';
import { auth0Client } from './auth0'; import { auth0Client } from './auth0';
import redirectWithMessage, { formatMessage } from './redirect-with-message'; 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 // eslint-disable-next-line @typescript-eslint/no-unsafe-return
jest.mock('../utils/env', () => ({ jest.mock('../utils/env', () => ({
@@ -23,7 +23,7 @@ describe('auth0 plugin', () => {
await fastify.register(cookies); await fastify.register(cookies);
await fastify.register(redirectWithMessage); await fastify.register(redirectWithMessage);
await fastify.register(codeFlowAuth); await fastify.register(auth);
await fastify.register(auth0Client); await fastify.register(auth0Client);
await fastify.register(prismaPlugin); await fastify.register(prismaPlugin);
}); });
+163
View File
@@ -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);
});
});
});
+48
View File
@@ -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'] });
-328
View File
@@ -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 });
});
});
});
-106
View File
@@ -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'] });
+1 -1
View File
@@ -75,4 +75,4 @@ const cookies: FastifyPluginCallback = (fastify, _options, done) => {
done(); done();
}; };
export default fp(cookies); export default fp(cookies, { name: 'cookies' });