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("//")) { if (security_useHttps) { return host.replace(/^http:\/\//, "https://"); } 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", // Use the authorization code flow with a query response so the // callback arrives as a GET. The default id_token flow uses a // form_post, whose body is drained by async session middleware // before it can be parsed ("stream is not readable"). authorizationParams: { response_type: "code", response_mode: "query", }, 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; };