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:
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);