Skip to content

Commit

Permalink
FEAT: Added external_auth functionality (#45)
Browse files Browse the repository at this point in the history
* Added external_auth functionality to allow for use in conjuction with nginx ingress controllers

* Lowercase all calls to req.headers
  • Loading branch information
rorylshanks authored Nov 5, 2024
1 parent aae2218 commit 76951fb
Show file tree
Hide file tree
Showing 8 changed files with 125 additions and 20 deletions.
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -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
Expand Down
3 changes: 2 additions & 1 deletion docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion lib/adminpage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
4 changes: 2 additions & 2 deletions lib/http.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})

Expand All @@ -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)
Expand Down
95 changes: 85 additions & 10 deletions lib/sso.js
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 = ""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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);
}

Expand All @@ -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
};
8 changes: 8 additions & 0 deletions util/caddyModels.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down Expand Up @@ -395,6 +402,7 @@ async function generateCaddyConfig() {
getRedirectBasepath() + "/logout",
getRedirectBasepath() + "/auth",
getRedirectBasepath() + "/callback",
getRedirectBasepath() + "/external_verify",
config.jwks_path || getRedirectBasepath() + "/jwks.json"
]
}
Expand Down
30 changes: 26 additions & 4 deletions util/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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("/")) {
Expand Down Expand Up @@ -87,5 +108,6 @@ export {
getConfig,
getRouteFromRequest,
getRedirectBasepath,
getAuthListenPort
getAuthListenPort,
getExternalAuthRouteFromHostname
};
2 changes: 1 addition & 1 deletion util/logging.js
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down

0 comments on commit 76951fb

Please sign in to comment.