From 0b2e4dcc2e49d912a918b5d02b63849dbc26290d Mon Sep 17 00:00:00 2001 From: omkero Date: Fri, 3 Oct 2025 23:46:35 +0100 Subject: [PATCH] feat: add JWT authentication middleware --- .env | 1 + package-lock.json | 140 ++++++++++++++++++++++++++++++++++++++-------- package.json | 2 + src/server.js | 22 ++++++-- src/utils.js | 89 ++++++++++++++++++++++++++++- 5 files changed, 224 insertions(+), 30 deletions(-) create mode 100644 .env diff --git a/.env b/.env new file mode 100644 index 0000000000..03a0b71fde --- /dev/null +++ b/.env @@ -0,0 +1 @@ +YOUR_PRIVATE_KEY=PRIVATE_KEY_HERE diff --git a/package-lock.json b/package-lock.json index a6a9e84e3c..7fc9b1cd87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,9 +28,11 @@ "commander": "14.0.1", "copyfiles": "2.4.1", "cors": "2.8.5", + "dotenv": "^17.2.3", "express": "5.1.0", "handlebars": "4.7.8", "http-shutdown": "1.2.2", + "jsonwebtoken": "^9.0.2", "leaflet": "1.9.4", "leaflet-hash": "0.2.1", "maplibre-gl": "5.8.0", @@ -1744,6 +1746,7 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-6.1.5.tgz", "integrity": "sha512-vvmsN0r7rguA+FySiCsbaTTobSftpIDIpPW81trAmsv9TGxg3YCujAxRYp/Uy8xmDgYCzzgulG62H7KYUFmeIg==", "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^5.0.0", "@octokit/graphql": "^8.2.2", @@ -1980,19 +1983,6 @@ "@types/node": "*" } }, - "node_modules/@types/eslint": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", - "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2015,15 +2005,6 @@ "@types/geojson": "*" } }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/@types/minimist": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", @@ -2036,6 +2017,7 @@ "integrity": "sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.20.0" } @@ -2095,6 +2077,7 @@ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -2281,6 +2264,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2716,6 +2700,12 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -3460,6 +3450,7 @@ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -3779,6 +3770,18 @@ "node": ">=8" } }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3806,6 +3809,15 @@ "dev": true, "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -4048,6 +4060,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -4104,6 +4117,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -6217,6 +6231,49 @@ "node": "*" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kdbush": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", @@ -6370,17 +6427,46 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, "node_modules/lodash.isequal": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", "license": "MIT" }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", "license": "MIT" }, "node_modules/lodash.kebabcase": { @@ -6404,6 +6490,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/lodash.snakecase": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", @@ -6611,6 +6703,7 @@ "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.8.0.tgz", "integrity": "sha512-zLblPFK+z5sxeitDF8RL2cnqfRaivNwxbGoQMfwAm9st6d1lRGTxgI7NNNr/U1AEPkp5+X+wjROagiHvJD8aqg==", "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@mapbox/geojson-rewind": "^0.5.2", "@mapbox/jsonlint-lines-primitives": "^2.0.2", @@ -8285,6 +8378,7 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, diff --git a/package.json b/package.json index 726f580acd..a9e21b549a 100644 --- a/package.json +++ b/package.json @@ -53,9 +53,11 @@ "commander": "14.0.1", "copyfiles": "2.4.1", "cors": "2.8.5", + "dotenv": "^17.2.3", "express": "5.1.0", "handlebars": "4.7.8", "http-shutdown": "1.2.2", + "jsonwebtoken": "^9.0.2", "leaflet": "1.9.4", "leaflet-hash": "0.2.1", "maplibre-gl": "5.8.0", diff --git a/src/server.js b/src/server.js index 26f135d753..fab2a5a65b 100644 --- a/src/server.js +++ b/src/server.js @@ -21,6 +21,7 @@ import { getTileUrls, getPublicUrl, isValidHttpUrl, + keyValidationMiddleware, } from './utils.js'; import { fileURLToPath } from 'url'; @@ -153,7 +154,7 @@ async function start(opts) { options.dataDecoratorFunc = require( path.resolve(paths.root, options.dataDecorator), ); - } catch (e) {} + } catch (e) { } } const data = clone(config.data || {}); @@ -162,6 +163,16 @@ async function start(opts) { app.use(cors()); } + // Enable authentication middleware globally. + // This will protect ALL endpoints by requiring a valid JWT token + // to be passed as a `key` query parameter. + // + // Example: + // GET /api/resource?key= + // + // If you only want to protect specific routes, + // attach `keyValidationMiddleware` to those routes instead of globally. + app.use('/data/', serve_data.init(options, serving.data, opts)); app.use('/files/', express.static(paths.files)); app.use('/styles/', serve_style.init(options, serving.styles, opts)); @@ -736,11 +747,10 @@ async function start(opts) { if (opts.publicUrl) { baseUrl = opts.publicUrl; } else { - baseUrl = `${ - req.get('X-Forwarded-Protocol') - ? req.get('X-Forwarded-Protocol') - : req.protocol - }://${req.get('host')}/`; + baseUrl = `${req.get('X-Forwarded-Protocol') + ? req.get('X-Forwarded-Protocol') + : req.protocol + }://${req.get('host')}/`; } return { diff --git a/src/utils.js b/src/utils.js index e423d567df..7727e7e938 100644 --- a/src/utils.js +++ b/src/utils.js @@ -7,6 +7,9 @@ import clone from 'clone'; import { combine } from '@jsse/pbfont'; import { existsP } from './promises.js'; import { getPMtilesTile } from './pmtiles_adapter.js'; +import jwt from 'jsonwebtoken'; +import { configDotenv } from 'dotenv'; +configDotenv(); export const allowedSpriteFormats = allowedOptions(['png', 'json']); @@ -231,7 +234,7 @@ export function fixTileJSONCenter(tileJSON) { (tileJSON.bounds[1] + tileJSON.bounds[3]) / 2, Math.round( -Math.log((tileJSON.bounds[2] - tileJSON.bounds[0]) / 360 / tiles) / - Math.LN2, + Math.LN2, ), ]; } @@ -435,3 +438,87 @@ export async function fetchTileData(source, sourceType, z, x, y) { }); } } + +/** + * Middleware to validate a JWT token passed in the query string. + * + * This middleware expects a `key` parameter in the query string. + * It verifies the token using the server's symmetric HS256 secret key. + * + * @function keyValidationMiddleware + * @param {import("express").Request} req - The Express request object. + * @param {import("express").Response} res - The Express response object. + * @param {import("express").NextFunction} next - The next middleware function. + * @returns {void} - Calls `next()` if the token is valid, otherwise responds with 401 Unauthorized. + * + * @example + * // Example usage: + * app.use("/api", keyValidationMiddleware, apiRoutes); + */ +export function keyValidationMiddleware(req, res, next) { + const { key } = req.query; + + // load your Private Key it must be symmetric HS256 + var privateKey = process.env.YOUR_PRIVATE_KEY + if (!privateKey) { + throw new Error("Missing environment variable: YOUR_PRIVATE_KEY"); + } + + if (!key || !verifyJwtSignature(key, privateKey)) { + return res.status(401).send("Unauthorized: invalid or missing token"); + } + + next(); +} + +/** + * Signs a new JWT token with a given secret key and user identifier. + * + * Creates a JSON Web Token (JWT) using the HS256 algorithm. + * The token includes the provided `id` in its payload and expires in 1 hour. + * + * @function signJwtSignature + * @param {string} secretKey - The symmetric secret key used to sign the token. + * @param {string|number} id - The identifier to embed in the token payload. + * @returns {string} - A signed JWT token. + * + * @example + * const token = signJwtSignature(process.env.YOUR_PRIVATE_KEY, 12345); + * console.log("Generated Token:", token); + */ +function signJwtSignature(secrete_key, id) { + const payload = { + id: id, + } + const signingOptions = { + algorithm: 'HS256', // or your chosen algorithm + }; + const token = jwt.sign({ + data: payload + }, secrete_key, { expiresIn: "1h" }, signingOptions); + + return token; +} + +/** + * Verifies a JWT token using the provided secret key. + * + * Attempts to decode and validate the token + * If verification fails due to an invalid signature, malformed token, or expiration, it returns `false`. + * + * @function verifyJwtSignature + * @param {string} token - The JWT token to verify. + * @param {string} secretKey - The symmetric secret key used to verify the token. + * @returns {boolean} - Returns `true` if the token is valid, otherwise `false`. + */ +function verifyJwtSignature(token, secrete_key) { + try { + // it returns jwt payload if not the token is malformd or unvalid + const decoded = jwt.verify(token, secrete_key) + if (!decoded) { return false; } + + return true; + } catch (e) { + return false; + } +} \ No newline at end of file