/* * Copyright (c) All respective contributors to the Peridot Project. All rights reserved. * Copyright (c) 2021-2022 Rocky Enterprise Software Foundation, Inc. All rights reserved. * Copyright (c) 2021-2022 Ctrl IQ, Inc. All rights reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: * * 1. Redistributions of source code must retain the above copyright notice, * this list of conditions and the following disclaimer. * * 2. Redistributions in binary form must reproduce the above copyright notice, * this list of conditions and the following disclaimer in the documentation * and/or other materials provided with the distribution. * * 3. Neither the name of the copyright holder nor the names of its contributors * may be used to endorse or promote products derived from this software without * specific prior written permission. * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE * POSSIBILITY OF SUCH DAMAGE. */ import express from 'express'; import httpProxyMiddleware from 'http-proxy-middleware'; import bodyParser from 'body-parser'; import cookieParser from 'cookie-parser'; import helmet from 'helmet'; import webpack from 'webpack'; import webpackDevMiddleware from 'webpack-dev-middleware'; import webpackHotMiddleware from 'webpack-hot-middleware'; import webpackMildCompile from 'webpack-mild-compile'; import expressOidc from 'express-openid-connect'; import history from 'connect-history-api-fallback'; import hbs from 'hbs'; import evilDns from 'evil-dns'; import fs from 'fs'; import dns from 'dns'; const { createProxyMiddleware } = httpProxyMiddleware; const { auth } = expressOidc; export default async function (opts) { // Create a new app for health checks. const appZ = express(); appZ.get('/healthz', (req, res) => { res.end(); }); appZ.get('/_/healthz', (req, res) => { res.end(); }); const app = express(); app.use(function (req, res, next) { // Including byc-internal-req: 1 should return the Z page if (req.header('byc-internal-req') === 'yes') { appZ(req, res, next); } else { next(); } }); const prod = process.env.NODE_ENV === 'production'; const port = prod ? process.env.PORT || 8086 : opts.port; opts.secret = process.env.RESF_SECRET; // If we're in prod, then a secret has to be present if (prod && (!opts.secret || opts.secret.length < 32)) { throw 'secret has to be at least 32 characters'; } // Add authentication if not disabled if (!opts.disableAuth) { console.log(`Using issuer: ${opts.issuerBaseURL}`); console.log(`Using clientID: ${opts.clientID}`); console.log(`Using baseURL: ${opts.baseURL}`); if ( (opts.issuerBaseURL.endsWith('.localhost') || opts.issuerBaseURL.endsWith('.localhost/')) && process.env['RESF_ENV'] ) { const kong = 'kong-proxy.kong.svc.cluster.local'; const urlObject = new URL(opts.issuerBaseURL); console.warn(`Forcing ${urlObject.hostname} to resolve to ${kong}`); const lookup = async () => { return new Promise((resolve, reject) => { // noinspection HttpUrlsUsage dns.lookup(kong, { family: 4 }, (err, address, family) => { if (err) { reject(err); } resolve(address); }); }); }; const internalServiceResolve = await lookup(); evilDns.add(urlObject.hostname, internalServiceResolve); // Disable TLS verification for development process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = 0; } const config = { authRequired: process.env['DISABLE_AUTH_ENFORCE'] ? process.env['DISABLE_AUTH_ENFORCE'] === 'false' : !!!opts.disableAuthEnforce, // Disable telemetry enableTelemetry: false, // Use dev secret is none is present (Prod requires a secret so not a security issue) secret: opts.secret || 'dev-secret-123', // Add BaseURL for callback purposes. This has to be specified in the initial server call // The FRONTEND_URL environment variable can override this value in prod. baseURL: opts.baseURL, // The specific application should supply a dev client ID while prod IDs should be set as an env variable clientID: opts.clientID, // The specific application should supply a dev secret while prod secrets should be set as an env variable clientSecret: opts.clientSecret, issuerBaseURL: opts.issuerBaseURL, idpLogout: true, authorizationParams: { response_type: 'code', scope: 'openid profile email offline_access', }, session: { rolling: true, rollingDuration: 86400, absoluteDuration: 86400 * 7, }, routes: { callback: '/oauth2/callback', logout: '/oauth2/logout', login: '/oauth2/login', }, }; // If we have a authentication prefix, only force redirect on paths with that prefix // Remember, authentication done here is only for simplicity purposes. // The authentication token is then passed on to the API. // Bypassing auth here doesn't accomplish anything. let middlewares = []; // If requireEmailSuffix is present, let's validate post callback // that the signed in email ends with a suffix in the allowlist // Again, a bypass here doesn't accomplish anything. let requireEmailSuffix = opts.authOptions?.requireEmailSuffix; if (process.env['AUTH_OPTIONS_REQUIRE_EMAIL_SUFFIX']) { requireEmailSuffix = process.env['AUTH_OPTIONS_REQUIRE_EMAIL_SUFFIX'].split(','); } if (requireEmailSuffix) { middlewares.push((req, res, next) => { const email = req.oidc?.user?.email; if (!email) { return next('No email found in the user object'); } const suffixes = requireEmailSuffix; let isAllowed = false; for (const suffix of suffixes) { if (email.endsWith(suffix)) { isAllowed = true; break; } } if (isAllowed) { next(); } else { res.redirect( process.env['AUTH_REJECT_REDIRECT_URL'] ? process.env['AUTH_REJECT_REDIRECT_URL'] : opts.authOptions.authRejectRedirectUrl || 'https://rockylinux.org' ); } }); } app.use( (req, res, next) => { try { auth(config)(req, res, next); } catch (err) { next(err); } }, [middlewares] ); } // Currently in dev, webpack is handling all file serving // This is just a placeholder let distDir = process.cwd() + '/dist'; if (prod) { // Enable security hardening in prod app.use( helmet({ contentSecurityPolicy: false, }) ); // Prod expects a certain container structure for all apps // Packaging this application with the web base should do // all this for you const dirs = fs.readdirSync('/home/app/bundle'); distDir = `/home/app/bundle/${dirs[0]}`; } app.set('views', distDir); app.use(cookieParser()); app.set('view engine', 'hbs'); // Use the handlebar engine app.engine('hbs', hbs.__express); app.use(express.static(distDir)); if (opts.apis) { Object.keys(opts.apis).forEach((x) => { app.use(x, async (req, res, next) => { let authorization = ''; // If we have an authenticated user, send the token with the request if (req.oidc && req.oidc.accessToken) { let { access_token, isExpired, refresh } = req.oidc.accessToken; if (isExpired()) { try { ({ access_token } = await refresh()); } catch (err) { res.oidc.logout({ returnTo: '/' }); return next('User has to re-authenticate'); } } authorization = `Bearer ${access_token}`; if (!prod) { console.log(`Using id token: ${req.oidc.idToken}`); console.log(`Using access token: ${access_token}`); } } const rewrite = {}; rewrite[`^${x}`] = ''; // Make it possible to override api url using an env variable. // Example: /api can be set with URL_API // Example 2: /manage/api can be set with URL_MANAGE_API const prodEnvName = `URL_${x .substr(1) .replace('/', '_') .toUpperCase()}`; const apiUrl = process.env[prodEnvName] ? process.env[prodEnvName] : prod ? opts.apis[x].prodApiUrl : opts.apis[x].devApiUrl; createProxyMiddleware({ target: apiUrl, changeOrigin: true, headers: { host: apiUrl, authorization, }, pathRewrite: rewrite, })(req, res); }); }); } // Template parameters for values in initial state const templateParams = (req) => { // If auth is disabled, then either return an empty list // or run the templateFunc WITHOUT a user. // It's important that apps do not use `user` without validation if (opts.disableAuth || !req.oidc) { if (opts.templateFunc) { return opts.templateFunc(); } return {}; } const { user } = req.oidc; if (!user) { return {}; } if (opts.templateFunc) { return opts.templateFunc(user); } // Return default values const { email, name, picture } = user; return { email, name, picture, }; }; if (prod) { app.get('/*', (req, res) => { // Prod doesn't do hacky shit with the webpack compiler so just add the params // to the locals res.locals = templateParams(req); res.render('index'); }); } else { // Here comes the hack train if (!opts.webpackConfig && opts.webpackPath) { opts.webpackConfig = await import(opts.webpackPath); } // Create a live-reloading dev instance of the app with the given webpack config const compiler = webpack(opts.webpackConfig); webpackMildCompile(compiler); const wdm = webpackDevMiddleware(compiler, { publicPath: opts.webpackConfig.output.publicPath, }); app.use(history()); app.use((req, res, next) => { // Here we cache the old send function to re-use after we run the HTML through handlebars const oldSend = res.send; res.send = (data) => { let newData; // Check if the request returned a HTML page // For SPAs, the only HTML page is the index page if (res.get('content-type').indexOf('text/html') !== -1) { // Run through handlebars compiler with our template parameters newData = hbs.handlebars.compile(data.toString())( templateParams(req) ); } else { // No new data, just return old data newData = data; } // Re-replace res.send with the old res.send res.send = oldSend; // Run the old res.send with the new data return res.send(newData); }; next(); }); // Enable hot reload app.use(wdm); app.use(webpackHotMiddleware(compiler)); } // Enable JSON bodies. We're forwarding this to the API app.use(bodyParser.json()); console.log(`view app on ${opts.baseURL}`); app.listen(port); }