From 76951fb21adebcceaa89fd366fd915896fd5de2e Mon Sep 17 00:00:00 2001 From: Rory Shanks <6383578+rorylshanks@users.noreply.github.com> Date: Tue, 5 Nov 2024 23:10:29 +0100 Subject: [PATCH] FEAT: Added external_auth functionality (#45) * Added external_auth functionality to allow for use in conjuction with nginx ingress controllers * Lowercase all calls to req.headers --- Dockerfile | 2 +- docker-compose.yaml | 3 +- lib/adminpage.js | 1 - lib/http.js | 4 +- lib/sso.js | 95 ++++++++++++++++++++++++++++++++++++++++----- util/caddyModels.js | 8 ++++ util/config.js | 30 ++++++++++++-- util/logging.js | 2 +- 8 files changed, 125 insertions(+), 20 deletions(-) diff --git a/Dockerfile b/Dockerfile index 2a3b3b1..ec4fd8c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM caddy:2.7-alpine AS caddy +FROM caddy:2.8-alpine AS caddy FROM node:slim RUN apt update && apt upgrade -y && apt install -y ca-certificates supervisor diff --git a/docker-compose.yaml b/docker-compose.yaml index 5794501..759bade 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -13,7 +13,8 @@ services: - redis volumes: - $PWD:/appdata - - /secrets:/secrets + - ./secrets:/secrets + - ./test/e2e/configs/idp_output.json:/appdata/output.json idptest: image: ghcr.io/dexidp/dex:latest network_mode: host diff --git a/lib/adminpage.js b/lib/adminpage.js index 205f248..4e1d2c0 100644 --- a/lib/adminpage.js +++ b/lib/adminpage.js @@ -66,7 +66,6 @@ async function renderSessionsPage(req, res) { async function renderUsersPage(req, res) { try { var usersObj = await idp.getAllUsers() - console.log(usersObj) var users = [] for (var user of Object.keys(usersObj)) { users.push(usersObj[user]) diff --git a/lib/http.js b/lib/http.js index 3c5729b..3085d0d 100644 --- a/lib/http.js +++ b/lib/http.js @@ -66,7 +66,7 @@ app.use((req, res, next) => { if (!requestId) { requestId = randomUUID() } - req.headers["X-Veriflow-Request-Id"] = requestId + req.headers["x-veriflow-request-id"] = requestId next() }) @@ -84,7 +84,7 @@ if (config?.admin?.enable == true) { app.get(redirectBasePath + '/admin/users/details', admin.renderUserDetailsPage) } - +app.get(redirectBasePath + '/external_verify', ssoController.externalAuthVerify) app.get(redirectBasePath + '/verify', ssoController.verifyAuth) app.get(redirectBasePath + '/set', ssoController.setSessionCookie) diff --git a/lib/sso.js b/lib/sso.js index 4469eda..ebc2b1d 100644 --- a/lib/sso.js +++ b/lib/sso.js @@ -1,6 +1,6 @@ import { Issuer } from 'openid-client'; import { decodeJWT, createJWT } from '../util/jwt.js'; -import { getConfig, getRouteFromRequest, getRedirectBasepath } from '../util/config.js'; +import { getConfig, getRouteFromRequest, getRedirectBasepath, getExternalAuthRouteFromHostname } from '../util/config.js'; import { URL, URLSearchParams } from 'url'; import log from '../util/logging.js' import authz from './authz.js' @@ -28,7 +28,7 @@ function addParamsToUrl(baseUrl, params) { async function verifyAuth(req, res) { try { // First contract a URL object that can be parsed for various functions later, and get the current config - var requestUrl = new URL(`${req.get("X-Forwarded-Proto")}://${req.get("X-Forwarded-Host")}${req.get("X-Forwarded-Path") || ""}`) + var requestUrl = new URL(`${req.headers["x-forwarded-proto"]}://${req.headers["x-forwarded-host"]}${req.headers["x-forwarded-path"] || ""}`) var currentConfig = getConfig() // Then get the route-specific configuration for this hostname. Note that currently veriflow only supports per-host routes var route = getRouteFromRequest(req) @@ -88,16 +88,25 @@ async function verifyAuth(req, res) { return } + + // In the case where there is external auth enabled, we need to send a 401 as nginx ingress will handle the redirect + if (route.allow_external_auth === true && !req.query.rd) { + res.status(401).send("401 - external_auth") + return + } + // Finally, if the user is not logged in and has no other auth methods, create a JWT containging the original requested // URL, and redirect them to the AuthN flow for veriflow let jwtPayload = { - protocol: req.get('X-Forwarded-Proto') || "http", - host: req.get('Host'), - path: req.get('X-Forwarded-Path'), - query: req.get('X-Forwarded-Query') + protocol: req.headers['x-forwarded-proto'] || "http", + host: req.headers['x-forwarded-host'], + path: req.headers['x-forwarded-path'], + query: req.headers['x-forwarded-query'] } var redirectBasePath = getRedirectBasepath() + + // handle edge case where requested redirect URL is itself if (jwtPayload.path == redirectBasePath + "/verify") { jwtPayload.path = "/" jwtPayload.query = "" @@ -177,6 +186,11 @@ async function setSessionCookie(req, res) { req.session.cookie.expires = expireDate + if (req.session.external_auth === true) { + res.status(200).send("OK - Set") + return + } + var redirectProtocol = decoded.protocol var redirectHost = decoded.host var redirectPath = decoded.path @@ -266,7 +280,6 @@ async function redirectToSsoProvider(req, res) { async function verifySsoCallback(req, res) { try { - var currentConfig = getConfig() var oauthIssuer = await Issuer.discover(currentConfig.idp_provider_url) var redirectBasePath = getRedirectBasepath() @@ -349,12 +362,18 @@ async function handleRedirectToSetEndpoint(req, res) { var redirectProtocol = req.session.redirect.protocol var redirectHost = req.session.redirect.host var redirectPath = redirectBasePath + "/set" - var baseUrl = `${redirectProtocol}://${redirectHost}${redirectPath}` var redirectParams = { token: signedJwt } - var redirectUrl = addParamsToUrl(baseUrl, redirectParams) + if (req.session.external_auth === true) { + redirectPath = req.session.redirect.path + redirectParams = Object.assign(redirectParams, req.session.redirect.query) + } + + var baseUrl = `${redirectProtocol}://${redirectHost}${redirectPath}` + + var redirectUrl = addParamsToUrl(baseUrl, redirectParams) res.redirect(redirectUrl); } @@ -366,9 +385,65 @@ async function addSessionDetails(req) { } } +async function externalAuthVerify(req, res) { + var originalUrl = req.get("x-original-url") || req.query.rd + if (!originalUrl) { + log.error({ message: "x-original-url or redirect query not specified when using external_auth", context: { error: error.message, trace: error.stack } }) + var html = await errorpages.renderErrorPage(500, "ERR_NO_ORIGINAL_URL_SPECIFIED", req) + res.status(500).send(html) + } + try { + var parsedUrl = new URL(originalUrl) + var policy = getExternalAuthRouteFromHostname(parsedUrl.hostname) + if (!policy) { + log.error({ message: "no route found for specified redirect URL, or route not enabled for external auth", context: { error: error.message, trace: error.stack } }) + var html = await errorpages.renderErrorPage(404, "ERR_NO_ROUTE_FOUND", req) + res.status(500).send(html) + return + } + req.headers["x-veriflow-route-id"] = policy.routeId + req.headers["x-forwarded-proto"] = parsedUrl.protocol.slice(0, -1) + req.headers["x-forwarded-post"] = parsedUrl.hostname + req.headers["x-forwarded-path"] = parsedUrl.pathname + req.query.token = parsedUrl.searchParams.get("token") + req.session.external_auth = true + + if (req.query.token) { + // Handle the case where, when the user has logged in to the IdP, veriflow will redirect the user to the original URL, with a token added + // to the query string. If this token exists, take it and process it to set the session cookie for the users session + setSessionCookie(req, res) + } else { + // If the user is not authed on the nginx, they will get redirected to veriflow, with an "rd" in the query string + // Handle the case here, that the user is already authenticated in veriflow and just needs to have the cookie set again + if (req.session.loggedin && req.query.rd) { + // redirect to set endpoint + req.session.redirect = { + protocol: parsedUrl.protocol.slice(0, -1), + host: parsedUrl.hostname, + path: parsedUrl.pathname, + query: parsedUrl.search + } + handleRedirectToSetEndpoint(req, res) + } else { + // When the user has a session on the proxied URL, simply AuthZ the request + verifyAuth(req, res) + } + + } + + + } catch (error) { + log.error({ message: "unknown error in externalAuthVerify", context: { error: error.message, trace: error.stack } }) + var html = await errorpages.renderErrorPage(500, "ERR_INTERNAL_SERVER_ERROR", req) + res.status(500).send(html) + } + +} + export default { verifySsoCallback, redirectToSsoProvider, verifyAuth, - setSessionCookie + setSessionCookie, + externalAuthVerify }; \ No newline at end of file diff --git a/util/caddyModels.js b/util/caddyModels.js index 40c7056..da67fe7 100644 --- a/util/caddyModels.js +++ b/util/caddyModels.js @@ -286,6 +286,13 @@ async function saturateAllRoutesFromConfig(config) { for (var routeId in routes) { try { var route = routes[routeId] + + // If route has allow_external_auth configured, do not template it into the caddy config + if (route.allow_external_auth === true) { + log.info({ message: "Route is configured for allow_external_auth, will not template into proxy config", route: route }) + continue + } + var saturatedRoute = await saturateRoute(route, routeId) renderedRoutes.push(saturatedRoute) // log.debug({ "message": "Added route", route }) @@ -395,6 +402,7 @@ async function generateCaddyConfig() { getRedirectBasepath() + "/logout", getRedirectBasepath() + "/auth", getRedirectBasepath() + "/callback", + getRedirectBasepath() + "/external_verify", config.jwks_path || getRedirectBasepath() + "/jwks.json" ] } diff --git a/util/config.js b/util/config.js index 1bbafae..95dc35d 100644 --- a/util/config.js +++ b/util/config.js @@ -20,6 +20,22 @@ async function reloadConfig() { try { log.debug("Reloading configuration") var tempConfig = yaml.load(await fs.readFile(configFileLocation, 'utf8')); + for (var i in tempConfig.policy) { + var policy = tempConfig.policy[i] + tempConfig.external_auth = {} + if (policy.allow_external_auth === true) { + if (typeof policy.from === "string") { + try { + var parsedUrl = new URL(policy.from) + tempConfig.external_auth[parsedUrl.hostname] = policy + tempConfig.external_auth[parsedUrl.hostname].routeId = i + } catch (error) { + log.error({ message: "Failed to parse URL for external_auth for policy ", context: {error: error.message, stack: error.stack}}) + continue + } + } + } + } if (tempConfig) { currentConfig = tempConfig } @@ -40,20 +56,25 @@ function getConfig() { function getRouteFromRequest(req) { var config = getConfig() - var routeId = req.get("X-Veriflow-Route-Id") + var routeId = req.headers["x-veriflow-route-id"] if (!routeId) { - log.error({ message: "No route ID included in request", context: {route, hostname: hostname, numRoutes: config.policy.length}}) + log.error({ message: "No route ID included in request"}) return null } var route = config.policy[routeId] if (route) { return route } else { - log.error({ message: "Failed to find route for hostname", context: {route, hostname: hostname, numRoutes: config.policy.length}}) + log.error({ message: "Failed to find route for hostname"}) return null } } +function getExternalAuthRouteFromHostname(hostname) { + var config = getConfig() + return config.external_auth[hostname] +} + function getRedirectBasepath() { var redirectBasePath = getConfig().redirect_base_path || "/.veriflow" if (!redirectBasePath.startsWith("/")) { @@ -87,5 +108,6 @@ export { getConfig, getRouteFromRequest, getRedirectBasepath, - getAuthListenPort + getAuthListenPort, + getExternalAuthRouteFromHostname }; \ No newline at end of file diff --git a/util/logging.js b/util/logging.js index 087b932..d2d2366 100644 --- a/util/logging.js +++ b/util/logging.js @@ -5,7 +5,7 @@ const log = pino(); log.level = 30 log.access = (action, route, user, req) => { - let reqId = req.headers["X-Veriflow-Request-Id"] + let reqId = req.headers["x-veriflow-request-id"] let method = req.get("X-Forwarded-Method") let path = req.get("X-Forwarded-Path") let query = req.get("X-Forwarded-Query")