From 06d4076a45cf20dba106ce9fbe8bd57b45f7fa68 Mon Sep 17 00:00:00 2001 From: Oliver Eyton-Williams Date: Wed, 29 Mar 2023 14:38:38 +0200 Subject: [PATCH] feat(api): dev login (#49880) Co-authored-by: Mrugesh Mohapatra --- api/README.md | 14 +++-- api/package.json | 4 +- api/src/app.ts | 14 +++-- api/src/routes/{auth0.ts => auth.ts} | 85 +++++++++++++++++++--------- api/src/server.ts | 4 +- api/src/utils/env.ts | 21 ++++--- sample.env | 1 + 7 files changed, 94 insertions(+), 49 deletions(-) rename api/src/routes/{auth0.ts => auth.ts} (55%) diff --git a/api/README.md b/api/README.md index c0e8a92c6c0..5a14fd0f0b4 100644 --- a/api/README.md +++ b/api/README.md @@ -1,18 +1,24 @@ -# Connecting to local database +# Working on the new api + +## Connecting to local database The api uses the ORM Prisma and it needs the MongoDB instance to be a replica set. -## Atlas +### Atlas If you use MongoDB Atlas, the set is managed for you. -## Local +### Local The simplest way to run a replica set locally is to use the docker-compose file -in /tools. First disable any running MongoDB instance on your machin, then run +in /tools. First disable any running MongoDB instance on your machine, then run the docker-compose file. ```bash cd tools docker compose up -d ``` + +## Login in development/testing + +During development and testing, the api exposes the endpoint GET auth/dev-callback. Calling this will log you in as the user with the email `foo@bar.com` by setting the session cookie for that user. diff --git a/api/package.json b/api/package.json index c636a1cdc04..c355060bb1b 100644 --- a/api/package.json +++ b/api/package.json @@ -38,7 +38,7 @@ "name": "@freecodecamp/api", "nodemonConfig": { "env": { - "NODE_ENV": "development" + "FREECODECAMP_NODE_ENV": "development" }, "ignore": [ "**/*.js" @@ -53,7 +53,7 @@ "build": "tsc -p tsconfig.build.json", "clean": "rm -rf dist", "develop": "nodemon src/server.ts", - "start": "NODE_ENV=production node dist/server.js", + "start": "FREECODECAMP_NODE_ENV=production node dist/server.js", "test": "jest --force-exit", "prisma": "MONGOHQ_URL=mongodb://localhost:27017/freecodecamp?directConnection=true prisma", "postinstall": "prisma generate" diff --git a/api/src/app.ts b/api/src/app.ts index 3baaa336553..0202445040b 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -18,7 +18,7 @@ import fastifySwaggerUI from '@fastify/swagger-ui'; import jwtAuthz from './plugins/fastify-jwt-authz'; import sessionAuth from './plugins/session-auth'; import { testRoutes } from './routes/test'; -import { auth0Routes } from './routes/auth0'; +import { auth0Routes, devLoginCallback } from './routes/auth'; import { testValidatedRoutes } from './routes/validation-test'; import { testMiddleware } from './middleware'; import prismaPlugin from './db/prisma'; @@ -26,11 +26,12 @@ import prismaPlugin from './db/prisma'; import { AUTH0_AUDIENCE, AUTH0_DOMAIN, - NODE_ENV, + FREECODECAMP_NODE_ENV, MONGOHQ_URL, SESSION_SECRET, FCC_ENABLE_SWAGGER_UI, - API_LOCATION + API_LOCATION, + FCC_ENABLE_DEV_LOGIN_MODE } from './utils/env'; export type FastifyInstanceWithTypeProvider = FastifyInstance< @@ -60,7 +61,7 @@ export const build = async ( saveUninitialized: false, cookie: { maxAge: 1000 * 60 * 60, // 1 hour - secure: NODE_ENV !== 'development' + secure: FREECODECAMP_NODE_ENV !== 'development' }, store: MongoStore.create({ mongoUrl: MONGOHQ_URL @@ -104,7 +105,10 @@ export const build = async ( void fastify.register(prismaPlugin); void fastify.register(testRoutes); - void fastify.register(auth0Routes, { prefix: '/auth0' }); + void fastify.register(auth0Routes, { prefix: '/auth' }); + if (FCC_ENABLE_DEV_LOGIN_MODE) { + void fastify.register(devLoginCallback, { prefix: '/auth' }); + } void fastify.register(testValidatedRoutes); return fastify; }; diff --git a/api/src/routes/auth0.ts b/api/src/routes/auth.ts similarity index 55% rename from api/src/routes/auth0.ts rename to api/src/routes/auth.ts index f1f0c80ae64..67e9dae6770 100644 --- a/api/src/routes/auth0.ts +++ b/api/src/routes/auth.ts @@ -1,4 +1,8 @@ -import { FastifyPluginCallback } from 'fastify'; +import { + FastifyInstance, + FastifyPluginCallback, + FastifyRequest +} from 'fastify'; import { AUTH0_DOMAIN } from '../utils/env'; @@ -65,39 +69,64 @@ const defaultUser = { username: '' }; +const getEmailFromAuth0 = async (req: FastifyRequest) => { + 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'); + } + + const { email } = (await auth0Res.json()) as { email: string }; + return email; +}; + +const findOrCreateUser = async (fastify: FastifyInstance, email: string) => { + // TODO: handle the case where there are multiple users with the same email. + // e.g. use findMany and throw an error if more than one is found. + const existingUser = await fastify.prisma.user.findFirst({ + where: { email }, + select: { id: true } + }); + return ( + existingUser ?? + (await fastify.prisma.user.create({ + data: { ...defaultUser, email }, + select: { id: true } + })) + ); +}; + +export const devLoginCallback: FastifyPluginCallback = ( + fastify, + _options, + done +) => { + fastify.get('/dev-callback', async (req, _res) => { + const email = 'foo@bar.com'; + + const { id } = await findOrCreateUser(fastify, email); + req.session.user = { id }; + await req.session.save(); + }); + + done(); +}; + export const auth0Routes: FastifyPluginCallback = (fastify, _options, done) => { fastify.addHook('onRequest', fastify.authenticate); fastify.get('/callback', async (req, _res) => { - const auth0Res = await fetch( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - `https://${AUTH0_DOMAIN}/userinfo`, - { - headers: { - Authorization: req.headers.authorization ?? '' - } - } - ); + const email = await getEmailFromAuth0(req); - if (!auth0Res.ok) { - fastify.log.error(auth0Res); - throw new Error('Invalid Auth0 Access Token'); - } - - const { email } = (await auth0Res.json()) as { email: string }; - - const existingUser = await fastify.prisma.user.findFirst({ - where: { email } - }); - if (existingUser) { - req.session.user = { id: existingUser.id }; - } else { - const newUser = await fastify.prisma.user.create({ - data: { ...defaultUser, email } - }); - req.session.user = { id: newUser.id }; - } + const { id } = await findOrCreateUser(fastify, email); + req.session.user = { id }; await req.session.save(); }); + done(); }; diff --git a/api/src/server.ts b/api/src/server.ts index 2956b0f9b52..a70abe80069 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -1,6 +1,6 @@ import { build } from './app'; -import { NODE_ENV, PORT } from './utils/env'; +import { FREECODECAMP_NODE_ENV, PORT } from './utils/env'; const envToLogger = { development: { @@ -19,7 +19,7 @@ const envToLogger = { }; const start = async () => { - const fastify = await build({ logger: envToLogger[NODE_ENV] }); + const fastify = await build({ logger: envToLogger[FREECODECAMP_NODE_ENV] }); try { const port = Number(PORT); fastify.log.info(`Starting server on port ${port}`); diff --git a/api/src/utils/env.ts b/api/src/utils/env.ts index 8126f9df5c9..c3c63eed756 100644 --- a/api/src/utils/env.ts +++ b/api/src/utils/env.ts @@ -18,21 +18,20 @@ if (error) { `); } -function isAllowedEnv( - env: string -): env is 'development' | 'production' | 'test' { - return ['development', 'production', 'test'].includes(env); +function isAllowedEnv(env: string): env is 'development' | 'production' { + return ['development', 'production'].includes(env); } -assert.ok(process.env.NODE_ENV); -assert.ok(isAllowedEnv(process.env.NODE_ENV)); +assert.ok(process.env.FREECODECAMP_NODE_ENV); +assert.ok(isAllowedEnv(process.env.FREECODECAMP_NODE_ENV)); assert.ok(process.env.AUTH0_DOMAIN); assert.ok(process.env.AUTH0_AUDIENCE); assert.ok(process.env.API_LOCATION); assert.ok(process.env.SESSION_SECRET); assert.ok(process.env.FCC_ENABLE_SWAGGER_UI); +assert.ok(process.env.FCC_ENABLE_DEV_LOGIN_MODE); -if (process.env.NODE_ENV !== 'development' && process.env.NODE_ENV !== 'test') { +if (process.env.FREECODECAMP_NODE_ENV !== 'development') { assert.ok(process.env.PORT); assert.ok(process.env.MONGOHQ_URL); assert.notEqual( @@ -40,12 +39,16 @@ if (process.env.NODE_ENV !== 'development' && process.env.NODE_ENV !== 'test') { 'a_thirty_two_plus_character_session_secret', 'The session secret should be changed from the default value.' ); + assert.ok( + process.env.FCC_ENABLE_DEV_LOGIN_MODE !== 'true', + 'Dev login mode MUST be disabled in production.' + ); } export const MONGOHQ_URL = process.env.MONGOHQ_URL ?? 'mongodb://localhost:27017/freecodecamp?directConnection=true'; -export const NODE_ENV = process.env.NODE_ENV; +export const FREECODECAMP_NODE_ENV = process.env.FREECODECAMP_NODE_ENV; export const AUTH0_DOMAIN = process.env.AUTH0_DOMAIN; export const AUTH0_AUDIENCE = process.env.AUTH0_AUDIENCE; export const PORT = process.env.PORT || '3000'; @@ -53,3 +56,5 @@ export const API_LOCATION = process.env.API_LOCATION; export const SESSION_SECRET = process.env.SESSION_SECRET; export const FCC_ENABLE_SWAGGER_UI = process.env.FCC_ENABLE_SWAGGER_UI === 'true'; +export const FCC_ENABLE_DEV_LOGIN_MODE = + process.env.FCC_ENABLE_DEV_LOGIN_MODE === 'true'; diff --git a/sample.env b/sample.env index b04024c815c..02f95c31693 100644 --- a/sample.env +++ b/sample.env @@ -76,3 +76,4 @@ CODESEE=false NODE_ENV=development PORT=3000 FCC_ENABLE_SWAGGER_UI=true +FCC_ENABLE_DEV_LOGIN_MODE=true