From 06c40cc23f30c476a0116b37ba85694c110f8d59 Mon Sep 17 00:00:00 2001 From: Oliver Eyton-Williams Date: Wed, 8 Apr 2026 21:15:17 +0200 Subject: [PATCH] refactor: separate getAuthedUser from authorize (#66842) Co-authored-by: Ahmad Abdolsaheb --- api/src/plugins/auth.test.ts | 141 +++++++++++++++++++++++++++++++++++ api/src/plugins/auth.ts | 41 +++++++--- 2 files changed, 173 insertions(+), 9 deletions(-) diff --git a/api/src/plugins/auth.test.ts b/api/src/plugins/auth.test.ts index 64ae52e024d..f6807e03dfe 100644 --- a/api/src/plugins/auth.test.ts +++ b/api/src/plugins/auth.test.ts @@ -238,6 +238,147 @@ describe('auth', () => { }); }); + describe('req.getAuthedUser', () => { + test('returns message when access token is missing', async () => { + fastify.get('/test', async req => { + return req.getAuthedUser(); + }); + + const res = await fastify.inject({ + method: 'GET', + url: '/test' + }); + + expect(res.json()).toEqual({ + message: 'Access token is required for this request' + }); + }); + + test('returns message when access token is not signed', async () => { + fastify.get('/test', async req => { + return req.getAuthedUser(); + }); + + 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({ + message: 'Access token is required for this request' + }); + }); + + test('returns message when access token is invalid', async () => { + fastify.get('/test', async req => { + return req.getAuthedUser(); + }); + + 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({ + message: 'Your access token is invalid' + }); + }); + + test('returns message when access token has expired', async () => { + fastify.get('/test', async req => { + return req.getAuthedUser(); + }); + + 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({ + message: 'Access token is no longer valid' + }); + }); + + test('returns message when 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 } }; + + fastify.get('/test', async req => { + return req.getAuthedUser(); + }); + + 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({ + message: 'Your access token is invalid' + }); + }); + + test('returns user when 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', async req => { + return req.getAuthedUser(); + }); + + 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({ + user: fakeUser + }); + }); + }); + describe('onRequest Hook', () => { test('should update the jwt_access_token to httpOnly and secure', async () => { const rawValue = 'should-not-change'; diff --git a/api/src/plugins/auth.ts b/api/src/plugins/auth.ts index 0acf105d2c1..e1218f37a82 100644 --- a/api/src/plugins/auth.ts +++ b/api/src/plugins/auth.ts @@ -7,6 +7,16 @@ import { JWT_SECRET } from '../utils/env.js'; import { type Token, isExpired } from '../utils/tokens.js'; import { ERRORS } from '../exam-environment/utils/errors.js'; +type AuthResult = + | { + message: string; + user?: never; + } + | { + message?: never; + user: user; + }; + declare module 'fastify' { interface FastifyReply { setAccessTokenCookie: (this: FastifyReply, accessToken: Token) => void; @@ -16,6 +26,7 @@ declare module 'fastify' { // TODO: is the full user the correct type here? user: user | null; accessDeniedMessage: { type: 'info'; content: string } | null; + getAuthedUser: () => Promise; } interface FastifyInstance { @@ -60,26 +71,26 @@ const auth: FastifyPluginCallback = (fastify, _options, done) => { const setAccessDenied = (req: FastifyRequest, content: string) => (req.accessDeniedMessage = { type: 'info', content }); - const handleAuth = async (req: FastifyRequest): Promise => { - const tokenCookie = req.cookies.jwt_access_token; - if (!tokenCookie) return void setAccessDenied(req, TOKEN_REQUIRED); + async function getAuthedUser(this: FastifyRequest): Promise { + const tokenCookie = this.cookies.jwt_access_token; + if (!tokenCookie) return { message: TOKEN_REQUIRED }; - const unsignedToken = req.unsignCookie(tokenCookie); - if (!unsignedToken.valid) return void setAccessDenied(req, TOKEN_REQUIRED); + const unsignedToken = this.unsignCookie(tokenCookie); + if (!unsignedToken.valid) return { message: TOKEN_REQUIRED }; const jwtAccessToken = unsignedToken.value; try { jwt.verify(jwtAccessToken, JWT_SECRET); } catch { - return void setAccessDenied(req, TOKEN_INVALID); + return { message: TOKEN_INVALID }; } const { accessToken } = jwt.decode(jwtAccessToken) as { accessToken: Token; }; - if (isExpired(accessToken)) return void setAccessDenied(req, TOKEN_EXPIRED); + if (isExpired(accessToken)) return { message: TOKEN_EXPIRED }; // We're using token.userId since it's possible for the user record to be // malformed and for prisma to throw while trying to find the user. fastify.Sentry?.setUser({ @@ -89,8 +100,20 @@ const auth: FastifyPluginCallback = (fastify, _options, done) => { const user = await fastify.prisma.user.findUnique({ where: { id: accessToken.userId } }); - if (!user) return void setAccessDenied(req, TOKEN_INVALID); - req.user = user; + + return user ? { user } : { message: TOKEN_INVALID }; + } + + fastify.decorateRequest('getAuthedUser', getAuthedUser); + + const handleAuth = async (req: FastifyRequest): Promise => { + const { message, user } = await req.getAuthedUser(); + + if (user) { + req.user = user; + } else { + setAccessDenied(req, message); + } }; async function handleExamEnvironmentTokenAuth(