myheats

Live heats, scoring and leaderboard for sport events
git clone https://git.in0rdr.ch/myheats.git
Log | Files | Refs | Pull requests | README | LICENSE

commit 97e3e66db2986079b2a5f309f854585b85349cfc
parent ae971f03d8f02a93148c8c70f2cd341efb6355f2
Author: Andreas Gruhler <andreas.gruhler@adfinis.com>
Date:   Sun, 15 Sep 2024 22:30:04 +0200

feat(magic): add jwt backend api

Diffstat:
Asrc/api/db.cjs | 20++++++++++++++++++++
Asrc/api/server.cjs | 275+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 295 insertions(+), 0 deletions(-)

diff --git a/src/api/db.cjs b/src/api/db.cjs @@ -0,0 +1,20 @@ +const pg = require('postgres'); + +require('dotenv').config({ + // Configure common config files and modes + // - https://www.npmjs.com/package/dotenv + // - https://vitejs.dev/guide/env-and-mode + path: [ + '.env.local', + '.env.development', '.env.development.local', + '.env.test', '.env.test.local', + '.env.production', '.env.production.local', + '.env' + ] +}); + +module.exports = { + // will use psql environment variables + // https://github.com/porsager/postgres?tab=readme-ov-file#environmental-variables + sql: pg() +} diff --git a/src/api/server.cjs b/src/api/server.cjs @@ -0,0 +1,275 @@ +const http = require('node:http'); +const url = require('url'); +const jwt = require('jsonwebtoken'); +const nodemailer = require('nodemailer'); +const db = require('./db.cjs'); + +require('dotenv').config({ + // Configure common config files and modes + // - https://www.npmjs.com/package/dotenv + // - https://vitejs.dev/guide/env-and-mode + path: [ + '.env.local', + '.env.development', '.env.development.local', + '.env.test', '.env.test.local', + '.env.production', '.env.production.local', + '.env' + ] +}); + +// Read environment and set defaults +api_uri = process.env.MYHEATS_API; +api_port = process.env.MYHEATS_API_PORT; +redirect_uri = process.env.MYHEATS_REDIRECT_URI; +cors_allow_origin = process.env.MYHEATS_API_CORS_ALLOW_ORIGIN; +jwt_secret = process.env.MYHEATS_API_JWT_SECRET; +cors_allow_origin = (cors_allow_origin === undefined) ? "*" : cors_allow_origin; +api_uri = (api_uri === undefined) ? 'http://127.0.0.1' : api_uri; +api_port = (api_port === undefined) ? 8000 : api_port; +redirect_uri= (redirect_uri === undefined) ? "http://localhost:5173" : redirect_uri + +if (jwt_secret === undefined) { + console.log('Balls, MYHEATS_API_JWT_SECRET undefined 🍳'); + process.exit() +} else if (process.env.SMTP_HOST === undefined || + process.env.SMTP_PORT === undefined || + process.env.SMTP_STARTTLS === undefined || + process.env.SMTP_USER === undefined || + process.env.SMTP_FROM === undefined || + process.env.SMTP_PASSWORD === undefined) { + console.log('Balls, Not all SMTP settings defined 🍳'); + process.exit() +} + +// configure mail transport +const transport = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: process.env.SMTP_PORT, + secure: process.env.SMTP_STARTTLS, + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASSWORD, + }, +}); + +console.log("Backend API:", api_uri); +console.log("Backend API port:", api_port); +console.log("Redirect URI:", redirect_uri); +console.log("CORS Allow Origin:", cors_allow_origin); +console.log("SMTP server:", process.env.SMTP_HOST); +console.log("SMTP port:", process.env.SMTP_PORT); +console.log("SMTP starttls:", process.env.SMTP_STARTTLS); +console.log("* * * * * * *", "Uncle roger ready 🍚", "* * * * * * *"); + +// Create a simple http API server +// https://nodejs.org/en/learn/modules/anatomy-of-an-http-transaction +const server = http.createServer(async (req, res) => { + const url = new URL(`${api_uri}:${api_port}${req.url}`); + const search_params = url.searchParams; + + console.log('> Path', url.pathname); + console.log(' Method', req.method); + console.log(' Status code', req.client._httpMessage.statusCode); + + // set headers + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', cors_allow_origin); + + // cors pre-flight request uses options method + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + + if (req.method === 'GET') { + if (url.pathname === '/v1/healthz') { + res.end(JSON.stringify({ + message: 'egg fried rice 🍚 fuiyooh!', + })); + } else if (url.pathname === '/v1/auth/verify') { + try { + const token = search_params.get('token'); + + // lookup token in the database + const users = await lookupToken(token); + if (users.length < 1) { + throw {message: "Token not found"} + } + + const user = users[0] + + console.log(" Token expires_at:", users[0].expires_at); + console.log(" Time now, lah:", new Date()); + + // check expiration date of token in the database + if (new Date(users[0].expires_at) < new Date()) { + throw new Error('Token expired.. tthink about yu saad live 😿') + } + + // verify token signature + const v = jwt.verify(token, jwt_secret); + await invalidateToken(token); + console.log(" Verified token", v); + + if (v.email === user.email) { + res.end(JSON.stringify({ + message: 'Uncle roger verified 🀝 fuuiiyooh!', + data: user, + })); + } else { + throw {message: "Token signature invalid"} + } + } catch (error) { + serverError(res, error); + } + } + // cors pre-flight request uses options method + } else if (req.method === 'OPTIONS') { + res.end() + } else if (req.method === 'POST') { + if (url.pathname === '/v1/echo') { + let body = []; + req.on('data', chunk => { + body.push(chunk); + }).on('end', () => { + body = JSON.stringify({ + message: 'Uncle roger knows do egg fried rice since 3 years old, lah', + data: Buffer.concat(body).toString(), + }); + res.end(body); + }) + } else if (url.pathname === '/v1/auth/requestMagicLink') { + let body = []; + req.on('data', chunk => { + body.push(chunk); + }).on('end', async () => { + const b = Buffer.concat(body); + try { + if (b.length < 1) { + throw {message: "Empty request body"} + } + input = JSON.parse(b); + console.log(' Link request for:', input.email); + + const users = await getUser(input.email); + if (users.length < 1) { + throw {message: "User not found"} + } + + const user = users[0] + + // create magic link valid for 5m (300s) + const exp = new Date(Date.now() + 300000) + const token = jwt.sign({ email: user.email }, jwt_secret, { + expiresIn: 300, + }) + const magic_link = `${redirect_uri}?token=${token}` + const magic_href = `${api_uri}:${api_port}/v1/auth/verify?token=${token}` + console.log("πŸ”—Magic href:", magic_href) + + // save token with expiration time + await saveToken(user.id, token, exp) + + // send magic link to user + await transport.sendMail({ + from: process.env.SMTP_FROM, + to: user.email, + subject: 'MyHeats Magic Link', + text: `Click here ✨ to log in: ${magic_link}`, + }) + + res.end(JSON.stringify({ + message: 'Magic link requested', + })); + } catch (error) { + serverError(res, error); + } + }) + } else { + notFound(res); + } + } else { + notFound(res); + } +}); + +function notFound(res) { + res.statusCode = 404; + res.end(JSON.stringify({ + message: 'no tea cup 🍡 use fingaa πŸ‘‡ haaiiyaa!', + error: '404 not found', + })); +} +function serverError(res, err) { + console.log('x Error:', err); + res.statusCode = 500; + res.end(JSON.stringify({ + message: 'no colander, haaiiyaa, rice 🍚 wet you fucked up!', + error: err.message, + })); +} + +async function invalidateToken(token) { + try { + const users = await db.sql` + update public.judges set + token = null, + expires_at = null + where token = ${token} + ` + return users + } catch (error) { + console.log('Error occurred in invalidateToken:', error); + throw error + } +} + +async function lookupToken(token) { + try { + const users = await db.sql` + select * from public.judges where + token = ${token} + ` + return users + } catch (error) { + console.log('Error occurred in lookupToken:', error); + throw error + } +} + +async function saveToken(id, token, exp) { + try { + const users = await db.sql` + update public.judges set + token = ${token}, + expires_at = ${exp} + where id = ${id} + ` + return users + } catch (error) { + console.log('Error occurred in saveToken:', error); + throw error + } +} + +// Request a signed magic link +// https://clerk.com/blog/magic-links#building-a-magic-link-system +async function getUser(email) { + try { + const users = await db.sql` + select + id, + firstname, + lastname, + token, + email + from public.judges + where email = ${email} + ` + return users + } catch (error) { + console.log('Error occurred in getUser:', error); + throw error + } +} + +// listen and serve +server.listen(api_port);