From 838f30e2bee2509fdd946ddcaa2f511be3df1117 Mon Sep 17 00:00:00 2001 From: Oliver Eyton-Williams Date: Wed, 4 Sep 2024 03:38:50 +0200 Subject: [PATCH] feat(api): update mobile-login to match api-server (#55863) --- api/src/routes/auth.test.ts | 128 +++++++++++++++++++++++++++++++++++- api/src/routes/auth.ts | 36 +++++++--- 2 files changed, 154 insertions(+), 10 deletions(-) diff --git a/api/src/routes/auth.test.ts b/api/src/routes/auth.test.ts index f6ae0380640..7360704adc1 100644 --- a/api/src/routes/auth.test.ts +++ b/api/src/routes/auth.test.ts @@ -1,7 +1,30 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ -import { setupServer, superRequest } from '../../jest.utils'; +import { + setupServer, + superRequest, + createSuperRequest +} from '../../jest.utils'; import { AUTH0_DOMAIN } from '../utils/env'; +const mockedFetch = jest.fn(); +jest.spyOn(globalThis, 'fetch').mockImplementation(mockedFetch); + +const newUserEmail = 'a.n.random@user.com'; + +const mockAuth0NotOk = () => ({ + ok: false +}); + +const mockAuth0InvalidEmail = () => ({ + ok: true, + json: () => ({ email: 'invalid-email' }) +}); + +const mockAuth0ValidEmail = () => ({ + ok: true, + json: () => ({ email: newUserEmail }) +}); + jest.mock('../utils/env', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-return return { @@ -23,4 +46,107 @@ describe('auth0 routes', () => { expect(redirectUrl.pathname).toBe('/authorize'); }); }); + + describe('GET /mobile-login', () => { + let superGet: ReturnType; + + beforeAll(() => { + superGet = createSuperRequest({ method: 'GET' }); + }); + beforeEach(async () => { + await fastifyTestInstance.prisma.userRateLimit.deleteMany({}); + await fastifyTestInstance.prisma.user.deleteMany({ + where: { email: newUserEmail } + }); + }); + + it('should be rate-limited', async () => { + await Promise.all( + [...Array(10).keys()].map(() => superGet('/mobile-login')) + ); + + const res = await superGet('/mobile-login'); + expect(res.status).toBe(429); + }); + + it('should return 401 if the authorization header is invalid', async () => { + mockedFetch.mockResolvedValueOnce(mockAuth0NotOk()); + const res = await superGet('/mobile-login').set( + 'Authorization', + 'Bearer invalid-token' + ); + + expect(res.body).toStrictEqual({ + type: 'danger', + message: 'We could not log you in, please try again in a moment.' + }); + expect(res.status).toBe(401); + }); + + it('should return 400 if the email is not valid', async () => { + mockedFetch.mockResolvedValueOnce(mockAuth0InvalidEmail()); + const res = await superGet('/mobile-login').set( + 'Authorization', + 'Bearer valid-token' + ); + + expect(res.body).toStrictEqual({ + type: 'danger', + message: 'The email is incorrectly formatted' + }); + expect(res.status).toBe(400); + }); + + it('should set the jwt_access_token cookie if the authorization header is valid', async () => { + mockedFetch.mockResolvedValueOnce(mockAuth0ValidEmail()); + const res = await superGet('/mobile-login').set( + 'Authorization', + 'Bearer valid-token' + ); + + expect(res.status).toBe(200); + expect(res.get('Set-Cookie')).toEqual( + expect.arrayContaining([expect.stringMatching(/jwt_access_token=/)]) + ); + }); + + it('should create a user if they do not exist', async () => { + mockedFetch.mockResolvedValueOnce(mockAuth0ValidEmail()); + const existingUserCount = await fastifyTestInstance.prisma.user.count(); + + const res = await superGet('/mobile-login').set( + 'Authorization', + 'Bearer valid-token' + ); + + const newUserCount = await fastifyTestInstance.prisma.user.count(); + + expect(existingUserCount).toBe(0); + expect(newUserCount).toBe(1); + expect(res.status).toBe(200); + }); + + it('should redirect to returnTo if already logged in', async () => { + mockedFetch.mockResolvedValueOnce(mockAuth0ValidEmail()); + const firstRes = await superGet('/mobile-login').set( + 'Authorization', + 'Bearer valid-token' + ); + + expect(firstRes.status).toBe(200); + + const res = await superRequest('/mobile-login', { + method: 'GET', + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + setCookies: firstRes.get('Set-Cookie') + }) + .set('Authorization', 'Bearer does-not-matter') + .set('Referer', 'https://www.freecodecamp.org/back-home'); + + expect(res.status).toBe(302); + expect(res.headers.location).toBe( + 'https://www.freecodecamp.org/back-home' + ); + }); + }); }); diff --git a/api/src/routes/auth.ts b/api/src/routes/auth.ts index 2fcb4a53153..316a7af5492 100644 --- a/api/src/routes/auth.ts +++ b/api/src/routes/auth.ts @@ -3,25 +3,28 @@ import { FastifyPluginCallback, FastifyRequest } from 'fastify'; import rateLimit from 'express-rate-limit'; // @ts-expect-error - no types import MongoStoreRL from 'rate-limit-mongo'; +import isEmail from 'validator/lib/isEmail'; import { AUTH0_DOMAIN, MONGOHQ_URL } from '../utils/env'; import { auth0Client } from '../plugins/auth0'; +import { createAccessToken } from '../utils/tokens'; import { findOrCreateUser } from './helpers/auth-helpers'; -const getEmailFromAuth0 = async (req: FastifyRequest) => { +const getEmailFromAuth0 = async ( + req: FastifyRequest +): Promise => { const auth0Res = await fetch(`https://${AUTH0_DOMAIN}/userinfo`, { headers: { Authorization: req.headers.authorization ?? '' } }); - if (!auth0Res.ok) { - req.log.error(auth0Res); - throw new Error('Invalid Auth0 Access Token'); - } + if (!auth0Res.ok) return null; - const { email } = (await auth0Res.json()) as { email: string }; - return email; + // For now, we assume the response is a JSON object. If not, we can't proceed + // and the only safe thing to do is to throw. + const { email } = (await auth0Res.json()) as { email?: string }; + return typeof email === 'string' ? email : null; }; /** @@ -60,10 +63,25 @@ export const mobileAuth0Routes: FastifyPluginCallback = ( // all auth routes. fastify.addHook('onRequest', fastify.redirectIfSignedIn); - fastify.get('/mobile-login', async req => { + fastify.get('/mobile-login', async (req, reply) => { const email = await getEmailFromAuth0(req); - await findOrCreateUser(fastify, email); + if (!email) { + return reply.status(401).send({ + message: 'We could not log you in, please try again in a moment.', + type: 'danger' + }); + } + if (!isEmail(email)) { + return reply.status(400).send({ + message: 'The email is incorrectly formatted', + type: 'danger' + }); + } + + const { id } = await findOrCreateUser(fastify, email); + + reply.setAccessTokenCookie(createAccessToken(id)); }); done();