aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorRuben Beltran del Rio <jj@r.bdr.sh>2025-12-23 22:14:44 +0100
committerRuben Beltran del Rio <jj@r.bdr.sh>2025-12-23 22:17:09 +0100
commitaeb9ecadf80fd50290571071311877b39c80e962 (patch)
treeb9a5cbe979fbd4fb14c97b00534b6918df6b0d85 /src
Initial implementation1.0.0
Diffstat (limited to 'src')
-rw-r--r--src/client.js203
-rw-r--r--src/server.js327
2 files changed, 530 insertions, 0 deletions
diff --git a/src/client.js b/src/client.js
new file mode 100644
index 0000000..7c0de4a
--- /dev/null
+++ b/src/client.js
@@ -0,0 +1,203 @@
+const internals = {
+ kStyleSheet: "/security/style.css",
+ kClientSettingsURL: "/auth/client-settings.json",
+ kOpenLockSVG:
+ '<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" fill="none"><defs><clipPath id="a" class="frame-clip frame-clip-def"><rect width="128" height="128" rx="0" ry="0"/></clipPath></defs><g class="frame-container-wrapper"><g class="frame-container-blur"><g class="frame-container-shadows" clip-path="url(#a)"><g class="fills"><rect width="128" height="128" class="frame-background" rx="0" ry="0"/></g><g class="frame-children"><rect width="90" height="72" x="19" y="56" class="fills" rx="4" ry="4" style="fill:currentColor"/><path d="M28 39c2-16 1.001-38 37-38s35 21 34.997 56h-12C88 30 89 13 65 13c-24 0-24 21-25 26H28" class="fills" style="fill:currentColor"/></g></g></g></g></svg>',
+ kLockSVG:
+ '<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" fill="none"><defs><clipPath id="a" class="frame-clip frame-clip-def"><rect width="128" height="128" rx="0" ry="0"/></clipPath></defs><g class="frame-container-wrapper"><g class="frame-container-blur"><g class="frame-container-shadows" clip-path="url(#a)"><g class="fills"><rect width="128" height="128" class="frame-background" rx="0" ry="0"/></g><g class="frame-children"><rect width="90" height="72" x="19" y="56" class="fills" rx="4" ry="4" style="fill:currentColor"/><path d="M28 57c0-25 1-48 37-48s35 23 35 48H88c0-17 1-36-23-36S40 40 40 57H28" class="fills" style="fill:currentColor"/></g></g></g></g></svg>',
+ kKeySVG:
+ '<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" fill="none"><defs><clipPath id="a" class="frame-clip frame-clip-def"><rect width="128" height="128" rx="0" ry="0"/></clipPath></defs><g class="frame-container-wrapper"><g class="frame-container-blur"><g class="frame-container-shadows" clip-path="url(#a)"><g class="fills"><rect width="128" height="128" class="frame-background" rx="0" ry="0"/></g><g class="frame-children"><path d="M23.414 127.414A1.994 1.994 0 0 1 22 128H2c-1.104 0-2-.896-2-2v-20c0-.544.217-1.037.57-1.397l-.07-.07 48.392-48.392c-5.845-14.747-2.806-32.21 9.117-44.132 16-16.001 41.982-16.001 57.982 0 16.001 16 16.001 41.982 0 57.982-11.613 11.614-28.484 14.798-42.98 9.554L73 79.56a3.99 3.99 0 0 0-2.968 1.168l-5.304 5.304a4.022 4.022 0 0 0-.42.495l-5.423 5.422a2.02 2.02 0 0 1-1.385.551H46c-1.104 0-2 .896-2 2v11a4.004 4.004 0 0 1-3.108 3.9H33c-1.104 0-2 .896-2 2v8a1.913 1.913 0 0 1-.532.974l-6.788 6.789M105.14 22.859c-4-4-10.495-4-14.495 0s-4 10.495 0 14.495 10.495 4 14.495 0 4-10.495 0-14.495" class="fills" style="fill:currentColor"/></g></g></g></g></svg>',
+
+ settings: {},
+
+ makeLinkWithIcon(href, id, classes, title, icon) {
+ const link = document.createElement("a");
+ link.href = href;
+ link.id = id;
+ link.title = title;
+ link.classList.add(...classes);
+ link.innerHTML = icon;
+
+ return link;
+ },
+};
+
+const oidcSecurity = {
+ async setup() {
+ const hasStyleSheet = [...document.styleSheets].some((styleSheet) =>
+ styleSheet.href.endsWith(internals.kStyleSheet),
+ );
+
+ if (!hasStyleSheet) {
+ console.log("Adding security stylesheet.");
+ const link = document.createElement("link");
+ link.rel = "stylesheet";
+ link.href = internals.kStyleSheet;
+ link.type = "text/css";
+ document.getElementsByTagName("head")[0].appendChild(link);
+ }
+
+ const response = await fetch(internals.kClientSettingsURL, {
+ cache: "no-cache",
+ mode: "same-origin",
+ });
+
+ if (response.ok) {
+ const clientSettings = await response.json();
+ window.isOwner = clientSettings.isOwner;
+ internals.settings = clientSettings;
+
+ let dialogHost = window.location.hostname;
+ if (internals.settings.wikiHost) {
+ dialogHost = clientSettings.wikiHost;
+ }
+
+ internals.settings.cookieDomain = dialogHost;
+
+ if (window.location.port) {
+ dialogHost = `${dialogHost}:${window.location.port}`;
+ }
+
+ let dialogProtocol = window.location.protocol;
+ if (internals.settings.useHttps) {
+ dialogProtocol = "https:";
+ }
+ const dialogOrigin = `${dialogProtocol}//${dialogHost}`;
+ internals.settings.dialogOrigin = dialogOrigin;
+ internals.settings.dialogURL = `${dialogOrigin}${internals.settings.loginURL}`;
+ oidcSecurity.updateFooter(window.ownerName, window.isAuthenticated);
+
+ // Subscription to the dialog messages.
+ window.addEventListener("message", async (event) => {
+ if (event.origin !== internals.settings.dialogOrigin) {
+ return;
+ }
+ if (event.data?.event === "loginDone") {
+ if (
+ internals.settings.claimOnFirstLogin &&
+ !window.isClaimed
+ ) {
+ await oidcSecurity.claimWiki();
+ }
+ window.location.reload();
+ }
+ });
+ } else {
+ console.log("Unable to fetch client settings: ", response);
+ }
+ },
+
+ async claimWiki() {
+ if (!window.isClaimed) {
+ const response = await fetch(internals.settings.claimURL, {
+ cache: "no-cache",
+ mode: "same-origin",
+ credentials: "same-origin",
+ });
+ if (response.ok) {
+ const json = await response.json();
+ if (wiki.lineup.bestTitle() === "Login Required") {
+ location.reload();
+ } else {
+ const { owner } = json;
+ window.isClaimed = true;
+ window.isOwner = true;
+ oidcSecurity.updateFooter(owner, true);
+ }
+ } else {
+ console.log("Attempt to claim site failed", response);
+ }
+ }
+ },
+
+ updateFooter(ownerName, isAuthenticated) {
+ if (ownerName) {
+ document.querySelector("footer > #site-owner").innerHTML =
+ `Site Owned by: <span id="site-owner">${ownerName}</span>`;
+ }
+
+ document.querySelector("footer > #security").innerHTML = "";
+ if (isAuthenticated) {
+ let logoutTitle = "Not Owner : Sign-out";
+ let logoutIcon = internals.kLockSVG;
+ let logoutClass = ["footer-item", "notOwner"];
+ if (window.isOwner) {
+ logoutTitle = "Sign-out";
+ logoutIcon = internals.kOpenLockSVG;
+ logoutClass = ["footer-item"];
+ }
+
+ const logoutLink = internals.makeLinkWithIcon(
+ internals.settings.logoutURL,
+ "logout",
+ logoutClass,
+ logoutTitle,
+ logoutIcon,
+ );
+ document.querySelector("footer > #security").append(logoutLink);
+
+ logoutLink.addEventListener("click", async (event) => {
+ event.preventDefault();
+ const response = await fetch(internals.settings.logoutURL, {
+ cache: "no-cache",
+ mode: "same-origin",
+ credentials: "same-origin",
+ });
+ if (response.ok) {
+ window.isAuthenticated = false;
+ window.user = "";
+ document.cookie = `state=loggedOut;domain=.${internals.settings.cookieDomain}; path=/; max-age=60; sameSite=Strict;`;
+ oidcSecurity.updateFooter(ownerName, false);
+ } else {
+ console.log("Logout failed: ", response);
+ }
+ });
+
+ if (!window.isClaimed) {
+ const claimLink = internals.makeLinkWithIcon(
+ internals.settings.claimURL,
+ "claim",
+ ["footer-item", "claim"],
+ "Claim this Wiki",
+ internals.kKeySVG,
+ );
+ document.querySelector("footer > #security").append(claimLink);
+
+ claimLink.addEventListener("click", (event) => {
+ event.preventDefault();
+ oidcSecurity.claimWiki();
+ });
+ }
+ } else {
+ let signOnTitle = "Claim this wiki";
+ if (window.isClaimed) {
+ signOnTitle = "Wiki Owner Sign-on";
+ }
+
+ const securityLink = internals.makeLinkWithIcon(
+ internals.settings.dialogURL,
+ "show-security-dialog",
+ ["footer-item"],
+ signOnTitle,
+ internals.kLockSVG,
+ );
+ document.querySelector("footer > #security").append(securityLink);
+
+ securityLink.addEventListener("click", (event) => {
+ event.preventDefault();
+
+ document.cookie = `wikiName=${window.location.host};domain=.${internals.settings.cookieDomain}; path=/; max-age=300; sameSite=Strict;`;
+
+ // This dialog will emit a loginDone event when done.
+ // We listen to it to refresh.
+ window.open(
+ internals.settings.dialogURL,
+ "oidc",
+ "popup,height=600,width=375",
+ );
+ });
+ }
+ },
+};
+
+window.plugins.security = oidcSecurity;
diff --git a/src/server.js b/src/server.js
new file mode 100644
index 0000000..a833dad
--- /dev/null
+++ b/src/server.js
@@ -0,0 +1,327 @@
+const { access, constants, readFile, writeFile } = require("node:fs");
+const { join } = require("node:path");
+const { auth } = require("express-openid-connect");
+
+const internals = {
+ kLogoutURL: "/logout",
+ kLoginURL: "/auth/login",
+ kClaimURL: "/auth/claim-wiki",
+ kSuccessfulLoginURL: "/auth/success",
+ buildBaseURL({ url, wiki_domain, security_useHttps }) {
+ let host = wiki_domain || url;
+
+ if (host.includes("//")) {
+ return `${host}`;
+ }
+
+ const protocol = security_useHttps ? "https" : "http";
+ return `${protocol}://${host}`;
+ },
+};
+
+/**
+ * Creates a security handler for the wiki.
+ * @param {(...args: any[]) => void} log a logger function pre-configured to
+ * only log if debug is on.
+ * @param {(...args: any[]) => void} loga a logger function that always logs.
+ * @argv {Object} argv The wiki configuration.
+ */
+module.exports = function createHandler(
+ log,
+ loga,
+ {
+ id: idFile,
+ url,
+ wiki_domain,
+ admin,
+ security_useHttps,
+ oidc_claimOnFirstLogin,
+ oidc_clientID,
+ oidc_clientSecret,
+ oidc_idpLogout,
+ oidc_issuerBaseURL,
+ oidc_issuerName,
+ oidc_secret,
+ oidc_logoutRedirectURL,
+ restricted,
+ restrictedDomains,
+ restrictedUsers,
+ },
+) {
+ let owner = null;
+ const security = {
+ /**
+ * Reads the owner file to determine the owner.
+ */
+ retrieveOwner(callback) {
+ access(idFile, constants.F_OK, (error) => {
+ if (error) {
+ owner = null;
+ return callback();
+ }
+ readFile(idFile, "utf8", (error, data) => {
+ if (error) {
+ owner = null;
+ return callback(error);
+ }
+ owner = JSON.parse(data);
+ callback();
+ });
+ });
+ },
+
+ /**
+ * Reads the owner file to determine the owner.
+ */
+ getOwner() {
+ return owner?.name || "";
+ },
+
+ /**
+ * Sets a new owner.
+ */
+ setOwner(newOwner, callback) {
+ access(idFile, constants.F_OK, (error) => {
+ if (!error) {
+ return callback(new Error("Site already claimed"));
+ }
+
+ writeFile(idFile, JSON.stringify(newOwner), (error) => {
+ if (error) {
+ return callback(
+ new Error("Could not write owner to file."),
+ );
+ }
+ loga(`Claiming wiki ${url} for ${newOwner.name}`);
+ owner = newOwner;
+ callback();
+ });
+ });
+ },
+
+ /**
+ * Gets the current authenticated user.
+ */
+ getUser(req) {
+ return req.oidc?.user || "";
+ },
+
+ /**
+ * Checks if the current authenticated user is authorized.
+ */
+ isAuthorized(req) {
+ if (owner === null) {
+ loga("isAuthorized: Site not claimed");
+ return true;
+ }
+
+ const preferredUsername = req.oidc.user?.preferred_username;
+ return preferredUsername && preferredUsername === owner.username;
+ },
+
+ /**
+ * Checks if the current authenticated user is an admin.
+ */
+ isAdmin(req) {
+ if (!admin) {
+ return false;
+ }
+ if (!req.oidc?.user) {
+ return false;
+ }
+
+ const preferredUsername = req.oidc.user?.preferred_username;
+ return preferredUsername && preferredUsername === admin;
+ },
+
+ /**
+ * Defines security routes for the wiki.
+ */
+ defineRoutes(app, _, updateOwner) {
+ const viewsPath = join(__dirname, "..", "views");
+
+ const config = {
+ authRequired: false,
+ idpLogout: !!oidc_idpLogout,
+ baseURL: internals.buildBaseURL({
+ url,
+ wiki_domain,
+ security_useHttps,
+ }),
+ clientID: oidc_clientID,
+ clientSecret: oidc_clientSecret,
+ clientAuthMethod: "client_secret_post",
+ issuerBaseURL: oidc_issuerBaseURL,
+ secret: oidc_secret,
+ enableTelemetry: false,
+ routes: {
+ login: false,
+ logout: false,
+ callback: "/auth/oidc/callback",
+ },
+ };
+
+ app.use(auth(config));
+
+ // Starts the OIDC login process.
+ app.get("/auth/oidc", (_, res) =>
+ res.oidc.login({
+ returnTo: internals.kSuccessfulLoginURL,
+ }),
+ );
+
+ // Displays the login dialog.
+ app.get(internals.kLoginURL, (_, res) => {
+ res.render(join(viewsPath, "login.html"), {
+ issuerName: oidc_issuerName || "SSO",
+ wikiName: wiki_domain || url || "wiki",
+ });
+ });
+
+ // Shown after a successful login.
+ app.get(internals.kSuccessfulLoginURL, (req, res) => {
+ if (!req.oidc.isAuthenticated()) {
+ return res.redirect(internals.kLoginURL);
+ }
+
+ const username =
+ req.oidc.user.preferred_username ||
+ req.oidc.user.name ||
+ "User";
+ res.render(join(viewsPath, "success.html"), { username });
+ });
+
+ // Claims a wiki.
+ app.get(internals.kClaimURL, (req, res) => {
+ if (!req.oidc.isAuthenticated()) {
+ return res.status(401).json({ error: "Not authenticated" });
+ }
+
+ const user = req.oidc.user;
+ const newOwner = {
+ name: user.name || user.preferred_username || user.email,
+ username: user.preferred_username || user.email,
+ };
+
+ security.setOwner(newOwner, (error) => {
+ if (error) {
+ loga(`Failed to claim wiki: ${error.message}`);
+ return res.status(400).json({ error: error.message });
+ }
+ log(`Wiki claimed by ${newOwner.name}`);
+ updateOwner(newOwner.name);
+ res.json({ success: true, owner: newOwner.name });
+ });
+ });
+
+ // Settings sent to the client.
+ app.get("/auth/client-settings.json", (req, res) => {
+ const authenticated = req.oidc.isAuthenticated();
+ const settings = {
+ claimOnFirstLogin: !!oidc_claimOnFirstLogin,
+ logoutURL: internals.kLogoutURL,
+ loginURL: internals.kLoginURL,
+ claimURL: internals.kClaimURL,
+ wikiHost: wiki_domain,
+ useHttps: security_useHttps,
+ owner: owner ? owner.name : null,
+ isOwner: authenticated && security.isAuthorized(req),
+ };
+ res.json(settings);
+ });
+
+ app.get(internals.kLogoutURL, (_, res) => {
+ const returnTo = oidc_logoutRedirectURL || "/";
+ res.oidc.logout({
+ returnTo,
+ });
+ });
+
+ if (restricted) {
+ const allowedToView = (req) => {
+ if (!req.oidc?.user) {
+ return false;
+ }
+
+ const user = req.oidc.user;
+ const username = user.preferred_username || user.email;
+
+ if (restrictedDomains) {
+ const domains = restrictedDomains
+ .split(",")
+ .map((d) => d.trim());
+ const email = user.email || "";
+ const emailDomain = email.split("@")[1];
+
+ if (domains.includes(emailDomain)) {
+ return true;
+ }
+ }
+
+ if (restrictedUsers) {
+ const users = restrictedUsers
+ .split(",")
+ .map((u) => u.trim());
+
+ if (users.includes("*") || users.includes(username)) {
+ return true;
+ }
+ }
+
+ return false;
+ };
+
+ app.all("*", (req, res, next) => {
+ // Site flags are public.
+ if (req.url === "/favicon.png") {
+ return next();
+ }
+ if (!/\.(json|html)$/.test(req.url)) {
+ return next();
+ }
+
+ res.header(
+ "Access-Control-Allow-Origin",
+ req.get("Origin") || "*",
+ );
+ res.header("Access-Control-Allow-Credentials", "true");
+
+ if (
+ (security.isAuthorized(req) && owner !== null) ||
+ allowedToView(req)
+ ) {
+ return next();
+ }
+
+ // Allow HTML views.
+ const htmlMatch = req.url.match(/\/(.*)\.html/);
+ if (htmlMatch) {
+ return res.redirect(`/view/${htmlMatch[1]}`);
+ }
+
+ // Sitemap is public.
+ if (req.url === "/system/sitemap.json") {
+ return res.json(["Login Required"]);
+ }
+
+ // If not allowed, show the login page.
+ const problem =
+ "This is a restricted wiki requires users to login to view pages. You do not have to be the site owner but you do need to login with a participating email address.";
+ const details = `[http://ward.asia.wiki.org/login-to-view.html details]`;
+ res.status(200).json({
+ title: "Login Required",
+ story: [
+ {
+ type: "paragraph",
+ id: "55d44b367ed64875",
+ text: `${problem} ${details}`,
+ },
+ ],
+ });
+ });
+ }
+ },
+ };
+
+ return security;
+};