diff --git a/api/src/app.ts b/api/src/app.ts index 7a873d8edf91ea..2dc8f06e16b0b2 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -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); + + 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) { diff --git a/api/src/plugins/auth.test.ts b/api/src/plugins/auth.test.ts new file mode 100644 index 00000000000000..e74bff8467f97f --- /dev/null +++ b/api/src/plugins/auth.test.ts @@ -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); + }); + }); +}); diff --git a/api/src/plugins/auth.ts b/api/src/plugins/auth.ts new file mode 100644 index 00000000000000..dec00c38987866 --- /dev/null +++ b/api/src/plugins/auth.ts @@ -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'] }); diff --git a/api/src/plugins/auth0.test.ts b/api/src/plugins/auth0.test.ts index 9e6a67eeb1cec3..d083990f313889 100644 --- a/api/src/plugins/auth0.test.ts +++ b/api/src/plugins/auth0.test.ts @@ -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); }); diff --git a/api/src/plugins/bouncer.test.ts b/api/src/plugins/bouncer.test.ts new file mode 100644 index 00000000000000..8618d3a32f18d5 --- /dev/null +++ b/api/src/plugins/bouncer.test.ts @@ -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); + }); + }); +}); diff --git a/api/src/plugins/bouncer.ts b/api/src/plugins/bouncer.ts new file mode 100644 index 00000000000000..61d6f71121dacb --- /dev/null +++ b/api/src/plugins/bouncer.ts @@ -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'] }); diff --git a/api/src/plugins/code-flow-auth.test.ts b/api/src/plugins/code-flow-auth.test.ts deleted file mode 100644 index 86d91ad69300e2..00000000000000 --- a/api/src/plugins/code-flow-auth.test.ts +++ /dev/null @@ -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 }); - }); - }); -}); diff --git a/api/src/plugins/code-flow-auth.ts b/api/src/plugins/code-flow-auth.ts deleted file mode 100644 index 7b9e8cca6ebf08..00000000000000 --- a/api/src/plugins/code-flow-auth.ts +++ /dev/null @@ -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'] }); diff --git a/api/src/plugins/cookies.ts b/api/src/plugins/cookies.ts index 6ed7e3aa47b051..6611855da74e2e 100644 --- a/api/src/plugins/cookies.ts +++ b/api/src/plugins/cookies.ts @@ -75,4 +75,4 @@ const cookies: FastifyPluginCallback = (fastify, _options, done) => { done(); }; -export default fp(cookies); +export default fp(cookies, { name: 'cookies' });