Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FEAT: Added external_auth functionality #45

Merged
merged 2 commits into from
Nov 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading