diff --git a/package-lock.json b/package-lock.json
index 2386ca0..783638f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,20 +1,23 @@
{
"name": "fullsend",
- "version": "1.7.3",
+ "version": "1.8.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "fullsend",
- "version": "1.7.3",
+ "version": "1.8.0",
"license": "MIT",
"dependencies": {
"bcryptjs": "^2.4.3",
"body-parser": "^1.20.0",
"dotenv": "^16.0.1",
"express": "^4.18.1",
+ "express-session": "^1.17.3",
+ "jose": "^4.15.0",
"mariadb": "^3.0.0",
"nodemon": "^3.1.10",
+ "openid-client": "^5.2.0",
"path": "^0.12.7",
"twilio": "^5.10.3"
}
@@ -507,6 +510,37 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/express-session": {
+ "version": "1.18.2",
+ "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz",
+ "integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==",
+ "dependencies": {
+ "cookie": "0.7.2",
+ "cookie-signature": "1.0.7",
+ "debug": "2.6.9",
+ "depd": "~2.0.0",
+ "on-headers": "~1.1.0",
+ "parseurl": "~1.3.3",
+ "safe-buffer": "5.2.1",
+ "uid-safe": "~2.1.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/express-session/node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express-session/node_modules/cookie-signature": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="
+ },
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -836,6 +870,14 @@
"node": ">=0.12.0"
}
},
+ "node_modules/jose": {
+ "version": "4.15.9",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
+ "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/jsonwebtoken": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
@@ -927,6 +969,17 @@
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
+ "node_modules/lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/mariadb": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mariadb/-/mariadb-3.0.0.tgz",
@@ -1123,6 +1176,14 @@
"node": ">=0.10.0"
}
},
+ "node_modules/object-hash": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
+ "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -1135,6 +1196,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/oidc-token-hash": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz",
+ "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==",
+ "engines": {
+ "node": "^10.13.0 || >=12.0.0"
+ }
+ },
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -1146,6 +1215,28 @@
"node": ">= 0.8"
}
},
+ "node_modules/on-headers": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
+ "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/openid-client": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
+ "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
+ "dependencies": {
+ "jose": "^4.15.9",
+ "lru-cache": "^6.0.0",
+ "object-hash": "^2.2.0",
+ "oidc-token-hash": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -1235,6 +1326,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/random-bytes": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
+ "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -1553,6 +1652,17 @@
"node": ">= 0.6"
}
},
+ "node_modules/uid-safe": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
+ "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
+ "dependencies": {
+ "random-bytes": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/undefsafe": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
@@ -1603,6 +1713,11 @@
"engines": {
"node": ">=6.0"
}
+ },
+ "node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
}
},
"dependencies": {
@@ -1960,6 +2075,33 @@
"vary": "~1.1.2"
}
},
+ "express-session": {
+ "version": "1.18.2",
+ "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz",
+ "integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==",
+ "requires": {
+ "cookie": "0.7.2",
+ "cookie-signature": "1.0.7",
+ "debug": "2.6.9",
+ "depd": "~2.0.0",
+ "on-headers": "~1.1.0",
+ "parseurl": "~1.3.3",
+ "safe-buffer": "5.2.1",
+ "uid-safe": "~2.1.5"
+ },
+ "dependencies": {
+ "cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="
+ },
+ "cookie-signature": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="
+ }
+ }
+ },
"fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -2170,6 +2312,11 @@
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="
},
+ "jose": {
+ "version": "4.15.9",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
+ "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="
+ },
"jsonwebtoken": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
@@ -2248,6 +2395,14 @@
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="
},
+ "lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "requires": {
+ "yallist": "^4.0.0"
+ }
+ },
"mariadb": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mariadb/-/mariadb-3.0.0.tgz",
@@ -2378,11 +2533,21 @@
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="
},
+ "object-hash": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
+ "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw=="
+ },
"object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="
},
+ "oidc-token-hash": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz",
+ "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw=="
+ },
"on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -2391,6 +2556,22 @@
"ee-first": "1.1.1"
}
},
+ "on-headers": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
+ "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A=="
+ },
+ "openid-client": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
+ "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
+ "requires": {
+ "jose": "^4.15.9",
+ "lru-cache": "^6.0.0",
+ "object-hash": "^2.2.0",
+ "oidc-token-hash": "^5.0.3"
+ }
+ },
"parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -2455,6 +2636,11 @@
"side-channel": "^1.0.6"
}
},
+ "random-bytes": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
+ "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ=="
+ },
"range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -2671,6 +2857,14 @@
"mime-types": "~2.1.24"
}
},
+ "uid-safe": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
+ "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
+ "requires": {
+ "random-bytes": "~1.0.0"
+ }
+ },
"undefsafe": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
@@ -2710,6 +2904,11 @@
"version": "13.0.2",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-13.0.2.tgz",
"integrity": "sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ=="
+ },
+ "yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
}
}
}
diff --git a/package.json b/package.json
index 45c0fda..06ee37c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "fullsend",
- "version": "1.7.3",
+ "version": "1.8.0",
"description": "Fullsend allows allowed users to send bulk text messages to groups of recipients",
"main": "server.js",
"scripts": {
@@ -21,6 +21,9 @@
"body-parser": "^1.20.0",
"dotenv": "^16.0.1",
"express": "^4.18.1",
+ "express-session": "^1.17.3",
+ "openid-client": "^5.2.0",
+ "jose": "^4.15.0",
"mariadb": "^3.0.0",
"nodemon": "^3.1.10",
"path": "^0.12.7",
diff --git a/public/help.html b/public/help.html
index ccd8608..cdcc5a3 100644
--- a/public/help.html
+++ b/public/help.html
@@ -44,8 +44,8 @@
- Log in
- Log out
+ Log in
+ Log out
@@ -63,6 +63,10 @@ Fullsend
Changelog
+
v1.8.0
+
+ Adds (finally!) authentication via OpenID Connect (OIDC) and Keycloak. Users must have the fullsend_access role in Keycloak to use the application and fullsend_admin to administer it.
+
v1.7.3
Adds blocking and timeouts to discourage users from sending the same message multiple times before the server has completed ingesting the message/recipient pairs
diff --git a/public/index.html b/public/index.html
index a1a9dc0..f915a7b 100644
--- a/public/index.html
+++ b/public/index.html
@@ -44,7 +44,7 @@
- Log in
+ Log in
diff --git a/public/js/changepassword.js b/public/js/changepassword.js
index 233d2ab..d33813d 100644
--- a/public/js/changepassword.js
+++ b/public/js/changepassword.js
@@ -1,12 +1,5 @@
const loadUsers = async () => {
- const session = getCookie("fullsend_session");
- const users = (
- await (
- await fetch(`/auth/api/users`, {
- headers: { session: session },
- })
- ).json()
- ).data;
+ const users = (await (await fetch(`/auth/api/users`)).json()).data;
for (const user of users) {
document.getElementById(
"changePasswordUsername"
@@ -40,11 +33,9 @@ const changePassword = async () => {
}
if (error) return -1;
- const session = getCookie("fullsend_session");
-
const result = await fetch("/auth/api/users/update/password", {
method: "POST",
- headers: { "Content-Type": "application/json", session: session },
+ headers: { "Content-Type": "application/json" },
body: JSON.stringify({
userId: userId,
password: password,
diff --git a/public/js/fullsend.js b/public/js/fullsend.js
index b2d975f..1d4eb74 100644
--- a/public/js/fullsend.js
+++ b/public/js/fullsend.js
@@ -1,41 +1,35 @@
-//TODO: this doesn't actually need isLoggedIn here, I don't think...
-//Pretty sure the redirect if not logged in (line 73ish) will cover that
+const safeJsonFetch = async (url, options = {}) => {
+ try {
+ const resp = await fetch(url, options);
+ if (!resp.ok) {
+ console.error('Fetch failed', url, resp.status, resp.statusText);
+ return null;
+ }
+ return await resp.json();
+ } catch (e) {
+ console.error('Network error fetching', url, e && e.message);
+ return null;
+ }
+};
+
const getGroups = async () => {
- const session = getCookie("fullsend_session");
- return (
- await (
- await fetch("/auth/api/groups/insequence", {
- headers: { session: session },
- })
- ).json()
- ).data;
+ const resp = await safeJsonFetch("/auth/api/groups/insequence");
+ return resp ? resp.data : [];
};
const getContactNumbersInGroup = async (group) => {
- const session = getCookie("fullsend_session");
let numbers = [];
- const contacts = (
- await (
- await fetch(`/auth/api/group/${group}/contacts`, {
- headers: { session: session },
- })
- ).json()
- ).data;
+ const resp = await safeJsonFetch(`/auth/api/group/${group}/contacts`);
+ const contacts = resp ? resp.data : [];
for (const contact of contacts) {
numbers.push(contact.phone_number);
}
+ return numbers;
};
const getContacts = async () => {
- const session = getCookie("fullsend_session");
- const contacts = (
- await (
- await fetch("/auth/api/contacts?active=1&filtered=1", {
- headers: { session: session },
- })
- ).json()
- ).data;
- return contacts;
+ const resp = await safeJsonFetch("/auth/api/contacts?active=1&filtered=1");
+ return resp ? resp.data : [];
};
const getSelectedGroups = (categories = false) => {
@@ -71,7 +65,6 @@ const getSelectedIndividuals = () => {
const handleSwitch = async (e) => {
handleMessagePreview();
- const session = getCookie("fullsend_session");
const switches = document.getElementsByClassName("recipientSwitch");
const modalBody = document.getElementById("recipientModalBody");
const viewListButton = document.getElementById("viewRecipientList");
@@ -85,16 +78,8 @@ const handleSwitch = async (e) => {
}
if (switchList.length > 0) {
- const contactList = (
- await (
- await fetch(
- `/auth/api/groups/contacts?groups=${switchList.join(",")}`,
- {
- headers: { session: session },
- }
- )
- ).json()
- ).data;
+ const contactResp = await safeJsonFetch(`/auth/api/groups/contacts?groups=${switchList.join(",")}`);
+ const contactList = contactResp ? contactResp.data : [];
if (contactList.length > 0) {
modalBody.innerHTML = `
@@ -142,14 +127,14 @@ const sendMessage = async () => {
}
if (error) return -1;
- const session = getCookie("fullsend_session");
+
document.getElementById("sendButton").disabled = true;
document.getElementById("fullsendMessage").disabled = true;
const result = await fetch("/auth/api/messages/send", {
method: "POST",
- headers: { "Content-Type": "application/json", session: session },
+ headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message: message,
groups: selectedGroups,
diff --git a/public/js/group-management.js b/public/js/group-management.js
index 1c1bba8..42b000e 100644
--- a/public/js/group-management.js
+++ b/public/js/group-management.js
@@ -1,27 +1,10 @@
let recipientListReady = false;
let currentGroup;
-const getGroups = async () => {
- const session = getCookie("fullsend_session");
- return (
- await (
- await fetch("/auth/api/groups/insequence", {
- headers: { session: session },
- })
- ).json()
- ).data;
-};
+const getGroups = async () => (await (await fetch("/auth/api/groups/insequence")).json()).data;
const loadContacts = async () => {
- const session = getCookie("fullsend_session");
-
- const contacts = await (
- await (
- await fetch("/auth/api/contacts?active=1", {
- headers: { session: session },
- })
- ).json()
- ).data;
+ const contacts = (await (await fetch("/auth/api/contacts?active=1")).json()).data;
document.getElementById("groupManagementRecipientsLabel").style.display =
"block";
@@ -38,15 +21,7 @@ const loadContacts = async () => {
const setGroupContacts = async (groupId) => {
recipientListReady = false;
- const session = getCookie("fullsend_session");
-
- const contacts = await (
- await (
- await fetch(`/auth/api/group/${groupId}/contacts`, {
- headers: { session: session },
- })
- ).json()
- ).data;
+ const contacts = (await (await fetch(`/auth/api/group/${groupId}/contacts`)).json()).data;
const checkboxes = document.getElementsByClassName("recipient-switch");
for (const checkbox of checkboxes) {
@@ -62,8 +37,7 @@ const setGroupContacts = async (groupId) => {
};
const handleSwitch = async (e) => {
- const session = getCookie("fullsend_session");
-
+
if (!recipientListReady) return;
const userId = e.target.value;
const action = e.target.checked ? "add" : "remove";
@@ -71,7 +45,7 @@ const handleSwitch = async (e) => {
if (action == "add") {
const result = await fetch("/auth/api/groups/update/addcontact", {
method: "POST",
- headers: { "Content-Type": "application/json", session: session },
+ headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contactId: userId,
groupId: currentGroup,
@@ -81,7 +55,7 @@ const handleSwitch = async (e) => {
} else if (action == "remove") {
const result = await fetch("/auth/api/groups/update/removecontact", {
method: "POST",
- headers: { "Content-Type": "application/json", session: session },
+ headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contactId: userId,
groupId: currentGroup,
diff --git a/public/js/login.js b/public/js/login.js
index 372191b..962e597 100644
--- a/public/js/login.js
+++ b/public/js/login.js
@@ -4,30 +4,8 @@ const handle403 = () => {
};
const login = async () => {
- const username = document.getElementById("loginUsername").value;
- const password = document.getElementById("loginPassword").value;
-
- const session = await (
- await fetch("/api/login", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- username: username,
- password: password,
- }),
- })
- ).json();
-
- if (session.code && session.code == 403) {
- return handle403();
- }
-
- const days = 5;
- const expires = new Date(Date.now() + days * 86400 * 1000).toUTCString();
-
- document.cookie = `fullsend_session=${await session.session}; expires=${expires}`;
-
- window.location.href = "/fullsend";
+ // Redirect to server which initiates Keycloak login
+ window.location.href = "/api/login";
};
const pageOnLoadFunctions = async () => {
diff --git a/public/js/main.js b/public/js/main.js
index 2652345..22afe1c 100644
--- a/public/js/main.js
+++ b/public/js/main.js
@@ -1,18 +1,4 @@
-const getCookie = (cname) => {
- let name = cname + "=";
- let decodedCookie = decodeURIComponent(document.cookie);
- let ca = decodedCookie.split(";");
- for (let i = 0; i < ca.length; i++) {
- let c = ca[i];
- while (c.charAt(0) == " ") {
- c = c.substring(1);
- }
- if (c.indexOf(name) == 0) {
- return c.substring(name.length, c.length);
- }
- }
- return undefined;
-};
+// cookie helper removed — server now manages sessions via OIDC
const getVersion = async () => {
const version = (await fetch("/api/version")).text();
@@ -24,64 +10,45 @@ const printVersionInNav = async () => {
};
const checkLogin = async () => {
- const session = getCookie("fullsend_session");
- const login = (
- await fetch("/auth/api/session/" + session, {
- headers: { session: session },
- })
- ).json();
- if ((await login).code == 401) {
- logout();
- }
- return login;
+ // Ask the server for session info (server reads token from session or Authorization)
+ const resp = await fetch('/auth/api/session/info');
+ if (resp.status === 200) return resp.json();
+ return { success: false };
};
const isLoggedIn = async () => {
- const session = getCookie("fullsend_session");
- if (!session) return false;
- const login = (
- await fetch("/auth/api/session/" + session, {
- headers: { session: session },
- })
- ).json();
- if ((await login).code == 401) {
- return false;
- }
-
- const days = 5;
- const expires = new Date(Date.now() + days * 86400 * 1000).toUTCString();
-
- document.cookie = `fullsend_session=${session}; expires=${expires}`;
-
- return true;
+ const info = await checkLogin();
+ return info && info.success;
};
const isAdmin = async (userId = null) => {
- const session = getCookie("fullsend_session");
- if (!session) return;
-
- if (!userId) {
- userId = (await checkLogin()).data[0].user_id;
+ const info = await checkLogin();
+ if (!info || !info.success) return false;
+ // If server returned localUser, respect that flag; otherwise inspect claims
+ if (info.data && info.data.localUser && info.data.localUser.admin) return true;
+ const claims = info.data && info.data.sessionInfo && info.data.sessionInfo.claims;
+ if (!claims) return false;
+ const realmRoles = (claims.realm_access && claims.realm_access.roles) || [];
+ // Server should expose which role name represents admin; fall back to 'admin'
+ const adminRole = (info.data && info.data.sessionInfo && info.data.sessionInfo.adminRole) || 'admin';
+ if (realmRoles.includes(adminRole)) return true;
+ if (claims.resource_access) {
+ // Try to find admin role in any client resource_access entry
+ for (const clientKey of Object.keys(claims.resource_access)) {
+ const ra = claims.resource_access[clientKey];
+ if (ra && ra.roles && ra.roles.includes(adminRole)) return true;
+ }
}
- const userInfo = (
- await (
- await fetch(`/auth/api/user/${userId}`, {
- headers: { session: session },
- })
- ).json()
- ).data[0];
- return userInfo.admin;
+ return false;
};
const logout = async () => {
- const session = getCookie("fullsend_session");
try {
- await fetch("/api/logout", { headers: { session } });
+ window.location.href = '/api/logout';
} catch (e) {
- console.error("Logout request failed:", e);
+ console.error('Logout failed', e);
+ window.location.href = '/';
}
- document.cookie = "fullsend_session=; expires=Thu, 01 Jan 1970 00:00:00 UTC;";
- window.location.href = "/";
};
const checkForRedirect = async () => {
@@ -101,7 +68,6 @@ const checkForRedirect = async () => {
for (const page of [authPages, adminPages]) {
if (window.location.pathname == page && !isLoggedInVar) {
- logout();
window.location.href = "/";
return;
}
diff --git a/public/no-access.html b/public/no-access.html
new file mode 100644
index 0000000..7b19aed
--- /dev/null
+++ b/public/no-access.html
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Fullsend | SMS notification from the web
+
+
+
+
+
+
+
Access denied
+
It looks like your account does not have permission to use Fullsend.
+
If you believe this is an error, please contact your administrator to request the fullsend_access role.
+
Return to home
+
+
+
\ No newline at end of file
diff --git a/public/privacy.html b/public/privacy.html
index 75e6c6d..01d13d3 100644
--- a/public/privacy.html
+++ b/public/privacy.html
@@ -44,8 +44,8 @@
- Log in
- Log out
+ Log in
+ Log out
diff --git a/public/terms.html b/public/terms.html
index 3010a4f..9255bd6 100644
--- a/public/terms.html
+++ b/public/terms.html
@@ -44,8 +44,8 @@
- Log in
- Log out
+ Log in
+ Log out
diff --git a/server.js b/server.js
index 570d87d..68622e0 100644
--- a/server.js
+++ b/server.js
@@ -11,7 +11,9 @@ const groups = require("./src/groups.js");
const titles = require("./src/titles.js");
const users = require("./src/users.js");
const sessions = require("./src/sessions.js");
+const auth = require("./src/auth.js");
const messages = require("./src/messages.js");
+const session = require('express-session');
const { version } = require("./package.json");
const { response } = require("express");
@@ -28,35 +30,22 @@ const pool = mariadb.createPool({
// Initialize express app
const PORT = process.env.PORT || 8080;
-const isLoggedIn = async (req, res, next) => {
- //"Checking session...
- if (req.headers.session) {
- //A session token was passed back, now checking if it is valid...
- const session = await sessions.getSession(pool, req.headers.session);
- if (session.data[0]) {
- req.body.sessionInfo = session.data[0];
- // Valid session token found
- sessions.sessionUpdate(pool, req.headers.session);
- next();
- } else {
- //The token passed back is invalid
- res.status(401).send({ code: 401, error: "Unauthorized" });
- }
- } else {
- // "No session token passed back
- res.status(401).send({ code: 401, error: "Unauthorized" });
- }
-};
+// Session middleware (required for server-side login flow)
+app.use(session({
+ secret: process.env.SESSION_SECRET || 'a very long secret',
+ resave: false,
+ saveUninitialized: false,
+ cookie: { secure: false }, // set secure: true if using HTTPS
+}));
-const isAdmin = async (req, res, next) => {
- const userInfo = (await users.getUser(pool, req.body.sessionInfo.user_id))
- .data[0];
- if (userInfo.admin == 1) {
- next();
- } else {
- res.status(403).send({ code: 403, error: "Forbidden" });
- }
-};
+// Initialize OIDC discovery (will be awaited before server starts)
+// Note: initOidc is async; we'll call it before starting the server below.
+
+const isLoggedIn = auth.isLoggedIn;
+
+// Use Keycloak roles for admin checks. If you want to rely on local DB admin flag
+// instead, change this to query users.getUserByUsername.
+const isAdmin = auth.isAdmin;
// auth router, anything on this router requires signin
const authRouter = express.Router();
@@ -101,6 +90,11 @@ app.get("/group-management", (req, res) => {
res.sendFile(path.join(__dirname, "public/group-management.html"));
});
+app.get("/no-access", (req, res) => {
+ res.sendFile(path.join(__dirname, "public/no-access.html"));
+});
+
+
app.get("/api", async (req, res) => {
res.send(`fullsend server is online v${version}`);
});
@@ -164,31 +158,117 @@ authRouter.get("/api/user/:user", async ({ params: { user: user } }, res) => {
res.send(response_data);
});
-authRouter.get(
- "/api/session/:session",
- async ({ params: { session: session } }, res) => {
- const response_data = await sessions.getSession(pool, session);
- res.send(response_data);
+authRouter.get("/api/session/info", async (req, res) => {
+ if (req.body.sessionInfo) {
+ const sessionInfo = req.body.sessionInfo;
+ // Expose the configured admin role name to the client so browser-side checks
+ // don't need to rely on Node-only process.env variables.
+ sessionInfo.adminRole = process.env.KEYCLOAK_ADMIN_ROLE || 'admin';
+ // Try to map to a local user record for convenience
+ let localUser = null;
+ try {
+ if (sessionInfo.username) {
+ const userResp = await users.getUserByUsername(pool, sessionInfo.username);
+ if (userResp && userResp.data && userResp.data[0]) {
+ localUser = userResp.data[0];
+ }
+ }
+ } catch (e) {
+ console.error('local user lookup failed', e && e.message);
+ }
+ // If we have a localUser stored in session (created during callback), prefer that
+ const sessionLocalUser = req.session && req.session.localUser ? req.session.localUser : localUser;
+ res.send({ success: true, data: { sessionInfo, localUser: sessionLocalUser } });
+ } else {
+ res.status(404).send({ success: false, error: "No session info" });
}
-);
+});
-app.get("/api/logout", async (req, res) => {
- const response_data = await sessions.logout(pool, req.headers.session);
- if (response_data.success) {
- response_data.data.insertId = response_data.data.insertId;
- res.send(response_data);
+app.get('/api/login', async (req, res) => {
+ try {
+ const url = await auth.getAuthorizationUrl(req);
+ return res.redirect(url);
+ } catch (err) {
+ console.error('login redirect failed', err && err.message);
+ return res.status(500).send({ error: 'Login redirect failed' });
}
});
-app.post("/api/login", async (req, res) => {
- const sessionId = await sessions.login(
- pool,
- req.body.username,
- req.body.password
- );
- sessionId
- ? res.send({ session: sessionId })
- : res.status(403).send({ code: 403, error: "Invalid login" });
+// Debug endpoint (no role enforcement) to inspect session info during development
+app.get('/api/debug/session', async (req, res) => {
+ try {
+ if (req.session && req.session.tokenSet) {
+ const sessionInfo = req.session.claims || (req.session.tokenSet.claims && req.session.tokenSet.claims());
+ return res.send({ success: true, data: { sessionInfo, localUser: req.session.localUser || null } });
+ }
+ return res.status(404).send({ success: false, error: 'No session' });
+ } catch (e) {
+ console.error('debug session failed', e && e.message);
+ return res.status(500).send({ success: false, error: 'Server error' });
+ }
+});
+
+app.get('/api/callback', async (req, res) => {
+ try {
+ await auth.handleCallback(req);
+ // Ensure a local user exists for the logged in Keycloak user
+ try {
+ const claims = req.session && req.session.claims;
+ if (claims) {
+ const addResp = await users.addUserIfNotExists(pool, claims);
+ if (addResp && addResp.success && addResp.user) {
+ // store local user on session for convenience
+ req.session.localUser = addResp.user;
+ }
+ }
+ } catch (e) {
+ console.error('addUserIfNotExists failed', e && e.message);
+ }
+
+ // After login, ensure the user has the required Fullsend role. If not,
+ // redirect to a friendly page explaining lack of access.
+ try {
+ const requiredRole = process.env.KEYCLOAK_FULLSEND_ROLE || 'fullsend_access';
+ const accessClaims = req.session && req.session.accessClaims ? req.session.accessClaims : (req.session && req.session.claims ? req.session.claims : null);
+ let hasRole = false;
+ if (accessClaims) {
+ const realmRoles = (accessClaims.realm_access && accessClaims.realm_access.roles) || [];
+ if (realmRoles.includes(requiredRole)) hasRole = true;
+ if (!hasRole && accessClaims.resource_access) {
+ for (const k of Object.keys(accessClaims.resource_access)) {
+ const ra = accessClaims.resource_access[k];
+ if (ra && ra.roles && ra.roles.includes(requiredRole)) { hasRole = true; break; }
+ }
+ }
+ }
+ if (!hasRole) {
+ return res.redirect('/no-access');
+ }
+ } catch (e) {
+ console.error('post-login role check failed', e && e.message);
+ }
+
+ // Redirect to app home or post-login page
+ return res.redirect('/fullsend');
+ } catch (err) {
+ console.error('callback handling failed', err && err.message);
+ return res.status(500).send({ error: 'Callback processing failed' });
+ }
+});
+
+app.get('/api/logout', (req, res) => {
+ try {
+ const logoutUrl = auth.getLogoutUrl(req);
+ // destroy local session
+ if (req.session) {
+ req.session.destroy(() => {});
+ }
+ if (logoutUrl) return res.redirect(logoutUrl);
+ return res.redirect('/');
+ } catch (err) {
+ console.error('logout failed', err && err.message);
+ return res.status(500).send({ error: 'Logout failed' });
+ }
});
authRouter.post("/api/users/update/password", isAdmin, async (req, res) => {
@@ -221,10 +301,21 @@ authRouter.post(
);
authRouter.post("/api/messages/send", async (req, res) => {
- const userId = await sessions.getSession(pool, req.headers.session);
+ // Derive local user id from Keycloak username
+ const username = req.body.sessionInfo && req.body.sessionInfo.username;
+ let userId;
+ if (username) {
+ const userResp = await users.getUserByUsername(pool, username);
+ if (userResp && userResp.data && userResp.data[0]) {
+ userId = userResp.data[0].id;
+ }
+ }
+
+ if (!userId) return res.status(403).send({ code: 403, error: "Forbidden" });
+
const response_data = await messages.sendMessage(
pool,
- userId.data[0].user_id,
+ userId,
req.body.message,
req.body.groups,
req.body.individuals
@@ -232,6 +323,14 @@ authRouter.post("/api/messages/send", async (req, res) => {
res.send(response_data);
});
-server.listen(PORT, () => {
- console.log("Fullsend is up!");
-});
+(async () => {
+ try {
+ await auth.initOidc();
+ server.listen(PORT, () => {
+ console.log("Fullsend is up!");
+ });
+ } catch (err) {
+ console.error('Failed to initialize OIDC:', err && err.message);
+ process.exit(1);
+ }
+})();
diff --git a/src/auth.js b/src/auth.js
new file mode 100644
index 0000000..e03f524
--- /dev/null
+++ b/src/auth.js
@@ -0,0 +1,258 @@
+const { Issuer, generators } = require("openid-client");
+const { createRemoteJWKSet, jwtVerify } = require("jose");
+
+let oidc = null;
+let client = null;
+
+async function initOidc() {
+ if (oidc) return oidc;
+ const issuerUrl = process.env.KEYCLOAK_ISSUER || process.env.KEYCLOAK_URL;
+ if (!issuerUrl) throw new Error("KEYCLOAK_ISSUER or KEYCLOAK_URL must be set (example: https://auth.example.com/realms/realmname)");
+
+ const issuer = await Issuer.discover(issuerUrl);
+ oidc = {
+ issuerUrl: issuer.issuer,
+ jwksUri: issuer.metadata.jwks_uri,
+ issuerObj: issuer,
+ };
+ return oidc;
+}
+
+async function initClient() {
+ if (client) return client;
+ const oidcCfg = await initOidc();
+ const issuer = oidcCfg.issuerObj;
+ const clientId = process.env.KEYCLOAK_CLIENT;
+ const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET;
+ const redirectUri = process.env.KEYCLOAK_REDIRECT_URI || "http://localhost:8080/api/callback";
+
+ if (!clientId) throw new Error("KEYCLOAK_CLIENT must be set for Authorization Code flow");
+
+ client = new issuer.Client({
+ client_id: clientId,
+ client_secret: clientSecret,
+ redirect_uris: [redirectUri],
+ response_types: ["code"],
+ });
+
+ return client;
+}
+
+async function getAuthorizationUrl(req) {
+ const oidcClient = await initClient();
+ const redirectUri = process.env.KEYCLOAK_REDIRECT_URI || "http://localhost:8080/api/callback";
+
+ const codeVerifier = generators.codeVerifier();
+ const codeChallenge = await generators.codeChallenge(codeVerifier);
+ const state = generators.random();
+
+ // store verifier+state in session
+ if (!req.session) throw new Error("Session middleware required for login flow");
+ req.session.code_verifier = codeVerifier;
+ req.session.state = state;
+
+ const url = oidcClient.authorizationUrl({
+ scope: "openid profile email",
+ redirect_uri: redirectUri,
+ code_challenge: codeChallenge,
+ code_challenge_method: "S256",
+ state,
+ });
+ return url;
+}
+
+async function handleCallback(req) {
+ const oidcClient = await initClient();
+ const redirectUri = process.env.KEYCLOAK_REDIRECT_URI || "http://localhost:8080/api/callback";
+ if (!req.session) throw new Error("Session middleware required for callback");
+ const params = oidcClient.callbackParams(req);
+ const codeVerifier = req.session.code_verifier;
+ const state = req.session.state;
+ const tokenSet = await oidcClient.callback(redirectUri, params, { code_verifier: codeVerifier, state });
+
+ // store tokenSet and claims in session
+ req.session.tokenSet = tokenSet;
+ req.session.claims = tokenSet.claims();
+
+ // Also verify / decode the access token to capture roles (Keycloak typically puts roles in the access token)
+ try {
+ const oidcCfg = await initOidc();
+ const JWKS = createRemoteJWKSet(new URL(oidcCfg.jwksUri));
+ const verifyOpts = { issuer: oidcCfg.issuerUrl };
+ if (process.env.KEYCLOAK_CLIENT) verifyOpts.audience = process.env.KEYCLOAK_CLIENT;
+ const accessToken = tokenSet.access_token;
+ if (accessToken) {
+ const { payload } = await jwtVerify(accessToken, JWKS, verifyOpts);
+ req.session.accessClaims = payload;
+ }
+ } catch (e) {
+ // Fallback: don't block login if access token verification fails; try to decode without verification
+ try {
+ const { decodeJwt } = require('jose');
+ if (tokenSet && tokenSet.access_token) {
+ req.session.accessClaims = decodeJwt(tokenSet.access_token);
+ }
+ } catch (e2) {
+ console.warn('access token decode failed', e2 && e2.message);
+ }
+ }
+ return tokenSet;
+}
+
+function getLogoutUrl(req) {
+ if (!oidc) return null;
+ const endSession = oidc.issuerObj.metadata.end_session_endpoint;
+ if (!endSession) return null;
+ const postLogout = process.env.KEYCLOAK_POST_LOGOUT_REDIRECT || "http://localhost:8080/";
+ const idToken = req.session && req.session.tokenSet && req.session.tokenSet.id_token;
+ const url = new URL(endSession);
+ if (idToken) url.searchParams.set('id_token_hint', idToken);
+ url.searchParams.set('post_logout_redirect_uri', postLogout);
+ return url.toString();
+}
+
+// Middleware: prefer session token, otherwise Authorization Bearer header
+async function isLoggedIn(req, res, next) {
+ try {
+ // If there's a token in session (server-side flow), use its claims
+ if (req.session && req.session.tokenSet) {
+ const oidcClient = await initClient();
+ const accessToken = req.session.tokenSet.access_token;
+ // Prefer the already-decoded accessClaims stored at login
+ let accessClaims = req.session.accessClaims || {};
+
+ // Try to verify the access token via JWKS (fast, no client secret required)
+ try {
+ const oidcCfg = await initOidc();
+ const JWKS = createRemoteJWKSet(new URL(oidcCfg.jwksUri));
+ const verifyOpts = { issuer: oidcCfg.issuerUrl };
+ if (process.env.KEYCLOAK_CLIENT) verifyOpts.audience = process.env.KEYCLOAK_CLIENT;
+ if (accessToken) {
+ const { payload } = await jwtVerify(accessToken, JWKS, verifyOpts);
+ accessClaims = payload || accessClaims;
+ }
+ } catch (e) {
+ // If jwt verification fails, don't immediately destroy session.
+ // If introspection is available (and client secret configured), use it to check active status.
+ try {
+ if (process.env.KEYCLOAK_CLIENT_SECRET && typeof oidcClient.introspect === 'function') {
+ const introspectResp = await oidcClient.introspect(accessToken);
+ if (!introspectResp || introspectResp.active !== true) {
+ try { req.session.destroy(() => {}); } catch (e2) {}
+ return res.status(401).send({ code: 401, error: 'Unauthorized' });
+ }
+ } else {
+ // As a last resort, try userinfo but do not destroy session if it fails; fall back to stored claims
+ try {
+ const userinfo = await oidcClient.userinfo(accessToken);
+ if (userinfo) accessClaims = userinfo;
+ } catch (e2) {
+ // ignore; we'll use whatever we have in session
+ }
+ }
+ } catch (e2) {
+ // ignore and continue with stored claims
+ }
+ }
+
+ // normalize sessionInfo to include access token claims (so downstream checks are uniform)
+ const idClaims = req.session.claims || (req.session.tokenSet.claims && req.session.tokenSet.claims());
+ req.body.sessionInfo = {
+ token: accessToken,
+ user_id: idClaims && idClaims.sub,
+ username: (idClaims && (idClaims.preferred_username || idClaims.username)),
+ email: idClaims && idClaims.email,
+ realm_access: accessClaims && accessClaims.realm_access,
+ resource_access: accessClaims && accessClaims.resource_access,
+ // expose the access token claims directly for easier checks
+ claims: accessClaims || {},
+ };
+
+ // Enforce fullsend_access role for all users
+ const requiredRole = process.env.KEYCLOAK_FULLSEND_ROLE || 'fullsend_access';
+ const hasRealm = (req.body.sessionInfo.realm_access && req.body.sessionInfo.realm_access.roles && req.body.sessionInfo.realm_access.roles.includes(requiredRole));
+ const hasResource = req.body.sessionInfo.resource_access && Object.values(req.body.sessionInfo.resource_access).some(r => r && r.roles && r.roles.includes(requiredRole));
+ if (!hasRealm && !hasResource) {
+ return res.status(403).send({ code: 403, error: `Forbidden - missing required role: ${requiredRole}` });
+ }
+
+ return next();
+ }
+
+ // otherwise fallback to Authorization header verification
+ const authHeader = req.headers.authorization || req.headers.Authorization;
+ if (!authHeader) return res.status(401).send({ code: 401, error: "Unauthorized" });
+ const match = String(authHeader).match(/Bearer (.+)/i);
+ if (!match) return res.status(401).send({ code: 401, error: "Unauthorized" });
+ const token = match[1];
+
+ const oidcCfg = await initOidc();
+ const JWKS = createRemoteJWKSet(new URL(oidcCfg.jwksUri));
+
+ const verifyOpts = {
+ issuer: oidcCfg.issuerUrl,
+ };
+ if (process.env.KEYCLOAK_CLIENT) verifyOpts.audience = process.env.KEYCLOAK_CLIENT;
+
+ const { payload } = await jwtVerify(token, JWKS, verifyOpts);
+ req.body.sessionInfo = {
+ token,
+ user_id: payload.sub,
+ username: payload.preferred_username || payload.username,
+ email: payload.email,
+ realm_access: payload.realm_access,
+ resource_access: payload.resource_access,
+ claims: payload,
+ };
+
+ // Enforce fullsend_access role for bearer tokens as well
+ const requiredRole = process.env.KEYCLOAK_FULLSEND_ROLE || 'fullsend_access';
+ const hasRealm = (payload.realm_access && payload.realm_access.roles && payload.realm_access.roles.includes(requiredRole));
+ const hasResource = payload.resource_access && Object.values(payload.resource_access).some(r => r && r.roles && r.roles.includes(requiredRole));
+ if (!hasRealm && !hasResource) {
+ return res.status(403).send({ code: 403, error: `Forbidden - missing required role: ${requiredRole}` });
+ }
+ return next();
+ } catch (e) {
+ console.error("isLoggedIn error", e && e.message);
+ return res.status(401).send({ code: 401, error: "Unauthorized" });
+ }
+}
+
+function isAdmin(req, res, next) {
+ // sessionInfo.claims is normalized to access token claims in isLoggedIn
+ const claims = req.body.sessionInfo && req.body.sessionInfo.claims;
+ if (!claims) return res.status(403).send({ code: 403, error: "Forbidden" });
+
+ const realmRoles = (claims.realm_access && claims.realm_access.roles) || [];
+ const clientRoles = [];
+ if (claims.resource_access) {
+ // Try to extract roles from configured client first
+ if (process.env.KEYCLOAK_CLIENT && claims.resource_access[process.env.KEYCLOAK_CLIENT]) {
+ const ra = claims.resource_access[process.env.KEYCLOAK_CLIENT];
+ if (ra && ra.roles) clientRoles.push(...ra.roles);
+ } else {
+ // otherwise flatten all client roles
+ for (const k of Object.keys(claims.resource_access)) {
+ const ra = claims.resource_access[k];
+ if (ra && ra.roles) clientRoles.push(...ra.roles);
+ }
+ }
+ }
+
+ const roles = [...realmRoles, ...clientRoles];
+ const adminRole = (req.body.sessionInfo && req.body.sessionInfo.adminRole) || process.env.KEYCLOAK_ADMIN_ROLE || "admin";
+ if (roles.includes(adminRole)) return next();
+
+ return res.status(403).send({ code: 403, error: "Forbidden" });
+}
+
+module.exports = {
+ initOidc,
+ initClient,
+ getAuthorizationUrl,
+ handleCallback,
+ getLogoutUrl,
+ isLoggedIn,
+ isAdmin,
+};
diff --git a/src/db.js b/src/db.js
index 686e086..5f682f9 100644
--- a/src/db.js
+++ b/src/db.js
@@ -1,8 +1,12 @@
const execQuery = async (pool, q, args = null, db = null) => {
let conn;
try {
- conn = await pool.getConnection();
- await conn.query(`USE ${db || process.env.PRIMARY_DB_NAME};`);
+ conn = await pool.getConnection();
+ const dbName = db || process.env.PRIMARY_DB_NAME;
+ // Wrap database name in backticks to allow hyphens and other chars.
+ // Also escape any backticks that might be present in the name.
+ const safeDbName = dbName ? `\`${String(dbName).replace(/`/g, '``')}\`` : null;
+ if (safeDbName) await conn.query(`USE ${safeDbName};`);
const results = await (args != null ? conn.query(q, args) : conn.query(q));
const response = { success: true };
if (results) {
diff --git a/src/sessions.js b/src/sessions.js
index 42a5693..b5cc6cf 100644
--- a/src/sessions.js
+++ b/src/sessions.js
@@ -1,53 +1,22 @@
-const { execQuery } = require("./db");
-const bcrypt = require("bcryptjs");
-const crypto = require("crypto");
+// Sessions are now managed by Keycloak OIDC. Keep compatibility exports
+// so other modules that import `sessions` won't immediately break.
-const AUTHENTICATE = "SELECT id, password FROM users WHERE username = ?";
-const SESSION_CREATE =
- "INSERT INTO sessions (`id`, `user_id`, `login`, `last_seen`, `expiration`) VALUES (?, ?, NOW(), NOW(), NOW() + INTERVAL 5 DAY)";
-const SESSION_DESTROY = "DELETE FROM sessions WHERE `id` = ?";
-const SESSION_UPDATE =
- "UPDATE sessions SET `last_seen` = NOW(), `expiration` = NOW() + INTERVAL 5 DAY WHERE `id` = ?";
-const SESSION_GET = "SELECT * FROM sessions WHERE id = ?";
-const SESSION_EXPIRED_DELETE = "DELETE FROM sessions WHERE expiration < NOW()";
-
-const deleteExpiredSessions = (pool) => {
- execQuery(pool, SESSION_EXPIRED_DELETE, null);
-};
-
-exports.getUsers = async (pool, sessionId) => {
- return execQuery(pool, SESSION_GET, sessionId);
-};
-
-exports.login = async (pool, username, password) => {
- deleteExpiredSessions(pool);
-
- const authedUser = await execQuery(pool, AUTHENTICATE, username);
- if (authedUser.data[0]) {
- const id = authedUser.data[0].id;
- const saved_hash = authedUser.data[0].password;
- const matches = bcrypt.compareSync(password, saved_hash);
- if (matches) {
- const session_id = crypto.randomBytes(20).toString("hex");
- const session_results = await execQuery(pool, SESSION_CREATE, [
- session_id,
- id,
- ]);
- return session_results.success ? session_id : undefined;
- }
- }
- return undefined;
+exports.getSession = async () => {
+ // Not applicable: session information is carried in the Keycloak grant on req.kauth
+ return { success: false, error: "Use Keycloak sessions via req.kauth" };
};
-exports.sessionUpdate = async (pool, sessionId) => {
- return execQuery(pool, SESSION_UPDATE, sessionId);
+exports.login = async () => {
+ // Local login is deprecated. Use Keycloak login flow.
+ return { success: false, error: "Use Keycloak OIDC login" };
};
-exports.logout = async (pool, sessionId) => {
- return execQuery(pool, SESSION_DESTROY, sessionId);
+exports.logout = async () => {
+ // Keycloak logout will be handled via Keycloak endpoint and middleware
+ return { success: false, error: "Use Keycloak logout endpoint" };
};
-exports.getSession = (pool, sessionId) => {
- deleteExpiredSessions(pool);
- return execQuery(pool, SESSION_GET, sessionId);
+exports.sessionUpdate = async () => {
+ // No-op under Keycloak
+ return { success: false, error: "Session updates handled by Keycloak" };
};
diff --git a/src/users.js b/src/users.js
index 0801a44..963c1f3 100644
--- a/src/users.js
+++ b/src/users.js
@@ -5,13 +5,19 @@ const USERS_GET =
"SELECT id, first_name, last_name, username, title, admin FROM users ORDER BY last_name";
const USER_GET =
"SELECT first_name, last_name, username, admin FROM users WHERE id = ?";
+const USER_GET_BY_USERNAME =
+ "SELECT id, first_name, last_name, username, admin FROM users WHERE username = ?";
const USER_ID_PHONE_GET = "SELECT phone_number FROM contacts WHERE user_id = ?";
const PASSWORD_UPDATE = "UPDATE users SET password = ? WHERE id = ?";
+const USER_CREATE = "INSERT INTO users (first_name, last_name, username, password, admin) VALUES (?, ?, ?, ?, 0)";
exports.getUsers = async (pool) => execQuery(pool, USERS_GET, null);
exports.getUser = async (pool, user) => execQuery(pool, USER_GET, user);
+exports.getUserByUsername = async (pool, username) =>
+ execQuery(pool, USER_GET_BY_USERNAME, username);
+
exports.getUserPhoneNumber = async (pool, user) => {
const result = await execQuery(pool, USER_ID_PHONE_GET, user);
if (result.data[0]) {
@@ -25,3 +31,32 @@ exports.changePassword = async (pool, user, plaintextPassword) => {
const hashedPassword = bcrypt.hashSync(plaintextPassword, 10);
return await execQuery(pool, PASSWORD_UPDATE, [hashedPassword, user]);
};
+
+// Creates a local user record from OIDC claims if the username does not already exist.
+exports.addUserIfNotExists = async (pool, claims) => {
+ if (!claims) return { success: false, error: 'No claims' };
+ const username = claims.preferred_username || claims.username || (claims.email ? claims.email.split('@')[0] : undefined);
+ if (!username) return { success: false, error: 'No username claim' };
+
+ // Check if user already exists
+ const existing = await exports.getUserByUsername(pool, username);
+ if (existing && existing.data && existing.data[0]) {
+ return { success: true, user: existing.data[0] };
+ }
+
+ // Build name fields from claims
+ const firstName = claims.given_name || (claims.name ? claims.name.split(' ')[0] : '');
+ const lastName = claims.family_name || (claims.name ? claims.name.split(' ').slice(1).join(' ') : '');
+
+ // Create a random password hash since authentication is via Keycloak
+ const randomPassword = Math.random().toString(36) + Date.now().toString(36);
+ const hashedPassword = bcrypt.hashSync(randomPassword, 10);
+
+ const createResp = await execQuery(pool, USER_CREATE, [firstName || '', lastName || '', username, hashedPassword]);
+ if (createResp && createResp.success) {
+ // Return the inserted user record (fetch by username to include id)
+ const newUser = await exports.getUserByUsername(pool, username);
+ return { success: true, user: newUser.data && newUser.data[0] };
+ }
+ return { success: false };
+};