v0.34.15
This commit is contained in:
1 parent
62b7574553
commit
e364392659
47 files changed
+2164
-257
No files matched your search
+27
-1
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "RustProxy Administration API",
|
||||
"version": "0.34.14",
|
||||
"version": "0.34.15",
|
||||
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust. The API also permits explicitly scoped GeneralApiBearer tokens on a narrow route allowlist, with RO/RW and source CIDR checks; API-token management and Prometheus-token management are browser-session-only."
|
||||
},
|
||||
"servers": [
|
||||
@@ -439,6 +439,24 @@
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"ForwardAuthConfig": {
|
||||
"type": "object",
|
||||
"description": "Per-Vhost Authelia or authentik verification. Disabled by default. All other settings are optional and use RustProxy defaults; when enabled, url must identify an internal HTTP(S) origin (or expected verifier path). Authelia additionally requires a public HTTPS login_url. External addresses retain normal safe upstream validation, with TLS verification always enabled for HTTPS.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean", "default": false},
|
||||
"url": {"type": "string", "maxLength": 2048, "description": "Internal service/outpost origin or the provider's verifier endpoint"},
|
||||
"login_url": {"type": "string", "maxLength": 2048, "description": "Required public HTTPS portal URL for Authelia; ignored for authentik"},
|
||||
"allow_private": {"type": "boolean", "default": false},
|
||||
"address_allowlist": {"type": "array", "maxItems": 32, "items": {"type": "string"}},
|
||||
"tls_ca_pem": {"type": "string", "nullable": true},
|
||||
"connect_timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 30, "default": 5},
|
||||
"timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 60, "default": 10},
|
||||
"request_headers": {"type": "array", "maxItems": 32, "items": {"type": "string", "maxLength": 128}, "description": "Only cookie, authorization, user-agent, accept, accept-language and x-api-key are allowed"},
|
||||
"response_headers": {"type": "array", "maxItems": 32, "items": {"type": "string", "maxLength": 128}, "description": "Allowed Remote-*, X-Auth-Request-* and X-Authentik-* identity headers; total injected identity data capped at 8 KiB"},
|
||||
"redirect_forbidden": {"type": "boolean", "default": false, "description": "Authelia only: accept trusted portal Location on explicit 403 if provided"}
|
||||
}
|
||||
},
|
||||
"Vhost": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -534,6 +552,14 @@
|
||||
"properties": {},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"authelia": {
|
||||
"$ref": "#/components/schemas/ForwardAuthConfig",
|
||||
"description": "Authelia ForwardAuth. Requires a public HTTPS login_url and /api/authz/forward-auth verifier. Disabled by default; mutually exclusive with basic_auth, tinyauth and authentik."
|
||||
},
|
||||
"authentik": {
|
||||
"$ref": "#/components/schemas/ForwardAuthConfig",
|
||||
"description": "authentik single-application forward auth. The URL is the internal outpost origin; RustProxy proxies its reserved path on the protected host. Disabled by default; mutually exclusive with basic_auth, tinyauth and authelia."
|
||||
},
|
||||
"cache": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -41,6 +41,8 @@ fn status_payload(state: &App) -> Value {
|
||||
"security_headers",
|
||||
"http3",
|
||||
"tinyauth",
|
||||
"authelia_forward_auth",
|
||||
"authentik_forward_auth",
|
||||
"trusted_forwarding",
|
||||
"named_listeners",
|
||||
"stream_proxy",
|
||||
|
||||
@@ -500,6 +500,16 @@ main {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.auth-provider-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px 18px;
|
||||
}
|
||||
|
||||
.auth-provider-options>.vh-check {
|
||||
flex: 1 1 250px;
|
||||
}
|
||||
|
||||
.auth-advanced {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 4px;
|
||||
|
||||
+4
-3
@@ -4,7 +4,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>502 - Tinyauth connection error - RustProxy</title>
|
||||
<title>{{STATUS}} - {{PROVIDER}} {{ERROR}} - RustProxy</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 12vh auto;
|
||||
@@ -29,8 +29,9 @@
|
||||
|
||||
<body>
|
||||
<main>
|
||||
<h1>502 - Tinyauth connection error</h1>
|
||||
<p>Unable to connect to the Tinyauth authentication service.</p>
|
||||
<h1>{{STATUS}} - {{PROVIDER}} {{ERROR}}</h1>
|
||||
<p>{{MESSAGE}}</p>
|
||||
<p>Access to the protected application is temporarily unavailable.</p>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>504 - Tinyauth timeout - RustProxy</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 12vh auto;
|
||||
padding: 24px;
|
||||
max-width: 620px;
|
||||
background: #131615;
|
||||
color: #e5e9e6;
|
||||
font: 15px/1.6 system-ui, sans-serif
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
margin: 0 0 8px
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #b9c3bc
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main>
|
||||
<h1>504 - Tinyauth timeout</h1>
|
||||
<p>The Tinyauth authentication service did not respond in time.</p>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,37 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>502 - Tinyauth verification error - RustProxy</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 12vh auto;
|
||||
padding: 24px;
|
||||
max-width: 620px;
|
||||
background: #131615;
|
||||
color: #e5e9e6;
|
||||
font: 15px/1.6 system-ui, sans-serif
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
margin: 0 0 8px
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #b9c3bc
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main>
|
||||
<h1>502 - Tinyauth verification error</h1>
|
||||
<p>The Tinyauth authentication service returned an invalid response.</p>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -33,6 +33,18 @@ function hostTagEditor(parent, hosts = [], onUpdate = () => { }) {
|
||||
return () => { if (input.value.trim()) consume(input.value); if (!values.length) throw new Error(t("vhHostRequired")); return [...values]; };
|
||||
}
|
||||
|
||||
function readForwardProvider(id) {
|
||||
const value = key => str(`${id}-${key}`);
|
||||
return {
|
||||
enabled: str("auth-mode") === id,
|
||||
url: value("url"), login_url: id === "authelia" ? value("login") : "",
|
||||
allow_private: yes(`${id}-private`), redirect_forbidden: yes(`${id}-redirect-forbidden`),
|
||||
connect_timeout_seconds: num(`${id}-connect`), timeout_seconds: num(`${id}-timeout`),
|
||||
request_headers: lines(value("request-headers")), response_headers: lines(value("response-headers")),
|
||||
address_allowlist: lines(value("allowlist")), tls_ca_pem: nullable(`${id}-ca`),
|
||||
};
|
||||
}
|
||||
|
||||
function load(value = defaults(), migrationIds = [], show = true) {
|
||||
draftEpoch++; stagedCertificates = []; uploading = false; backendHealthEditorControl = null;
|
||||
editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true;
|
||||
@@ -332,19 +344,18 @@ function load(value = defaults(), migrationIds = [], show = true) {
|
||||
const syncTlsPolicy = () => { for (const input of [tls12, tls13, tlsCiphers, tlsPqc]) input.disabled = !customTlsPolicy.checked; };
|
||||
customTlsPolicy.addEventListener("change", syncTlsPolicy); syncTlsPolicy(); note(g, "vhTlsOverrideHelp"); note(g, "tlsPqcVhostHelp");
|
||||
g = group("vhTinyauth"); const auth = value.tinyauth || {};
|
||||
const authMode = field(g, "auth-mode", "authMode", value.basic_auth?.enabled ? "basic" : auth.enabled ? "tinyauth" : "none", "select", { values: [["none", "authNone"], ["tinyauth", "authTinyauth"], ["basic", "authBasic"]] });
|
||||
const authMode = field(g, "auth-mode", "authMode", value.basic_auth?.enabled ? "basic" : auth.enabled ? "tinyauth" : value.authelia?.enabled ? "authelia" : value.authentik?.enabled ? "authentik" : "none", "select", { values: [["none", "authNone"], ["tinyauth", "authTinyauth"], ["authelia", "authAuthelia"], ["authentik", "authAuthentik"], ["basic", "authBasic"]] });
|
||||
note(g, "authModeHelp");
|
||||
|
||||
const taBody = el("section", undefined, "vh-wide auth-subcard"), basicBody = el("section", undefined, "vh-wide auth-subcard"); g.append(taBody, basicBody);
|
||||
taBody.append(text("h3", "authTinyauth"));
|
||||
const taPrimary = el("div", undefined, "vh-grid"); taBody.append(taPrimary);
|
||||
const authEnable = field(taPrimary, "auth-enabled", "taEnable", auth.enabled, "checkbox");
|
||||
const authUrl = field(taPrimary, "auth-url", "taUrl", auth.url || "", "url", { maxLength: 2048 });
|
||||
const loginUrl = field(taPrimary, "auth-login", "taLogin", auth.login_url || "", "url", { maxLength: 2048 });
|
||||
authUrl.placeholder = "http://10.87.7.99:3000"; loginUrl.placeholder = "https://auth.example.com/login";
|
||||
field(taPrimary, "auth-private", "vhPrivate", auth.allow_private, "checkbox");
|
||||
field(taPrimary, "auth-redirect-forbidden", "taRedirectForbidden", auth.redirect_forbidden !== false, "checkbox");
|
||||
authEnable.closest("label").hidden = true;
|
||||
const taOptions = el("div", undefined, "vh-wide auth-provider-options"); taPrimary.append(taOptions);
|
||||
field(taOptions, "auth-private", "vhPrivate", auth.allow_private, "checkbox");
|
||||
field(taOptions, "auth-redirect-forbidden", "taRedirectForbidden", auth.redirect_forbidden !== false, "checkbox");
|
||||
note(taBody, "taHelp");
|
||||
|
||||
const taAdvanced = el("details", undefined, "vh-wide auth-advanced"); taAdvanced.append(text("summary", "taAdvanced"));
|
||||
@@ -356,14 +367,49 @@ function load(value = defaults(), migrationIds = [], show = true) {
|
||||
field(taAdvancedGrid, "auth-allowlist", "vhAddressAllowlist", (auth.address_allowlist || []).join("\n"), "textarea", { rows: 2, maxLength: 8192 });
|
||||
field(taAdvancedGrid, "auth-ca", "vhCa", auth.tls_ca_pem || "", "textarea", { rows: 2, maxLength: 65536 });
|
||||
|
||||
const forwardEditors = {};
|
||||
for (const id of ["authelia", "authentik"]) {
|
||||
const current = value[id] || {}, title = id === "authelia" ? "authAuthelia" : "authAuthentik";
|
||||
const body = el("section", undefined, "vh-wide auth-subcard"); g.append(body);
|
||||
body.append(text("h3", title));
|
||||
const primary = el("div", undefined, "vh-grid"); body.append(primary);
|
||||
const url = field(primary, `${id}-url`, "forwardAuthUrl", current.url || "", "url", { maxLength: 2048 });
|
||||
url.placeholder = id === "authelia" ? "http://authelia:9091" : "http://authentik:9000";
|
||||
let login = null;
|
||||
if (id === "authelia") {
|
||||
login = field(primary, `${id}-login`, "forwardAuthLogin", current.login_url || "", "url", { maxLength: 2048 });
|
||||
login.placeholder = "https://auth.example.com";
|
||||
}
|
||||
const options = el("div", undefined, "vh-wide auth-provider-options"); primary.append(options);
|
||||
field(options, `${id}-private`, "vhPrivate", current.allow_private, "checkbox");
|
||||
field(options, `${id}-redirect-forbidden`, "taRedirectForbidden", current.redirect_forbidden === true, "checkbox");
|
||||
note(body, id === "authelia" ? "autheliaHelp" : "authentikHelp");
|
||||
const advanced = el("details", undefined, "vh-wide auth-advanced"); advanced.append(text("summary", "forwardAuthAdvanced"));
|
||||
const details = el("div", undefined, "vh-grid"); advanced.append(details); body.append(advanced);
|
||||
field(details, `${id}-connect`, "taConnect", current.connect_timeout_seconds ?? 5, "number", { required: true, min: 1, max: 30 });
|
||||
field(details, `${id}-timeout`, "taTimeout", current.timeout_seconds ?? 10, "number", { required: true, min: 1, max: 60 });
|
||||
field(details, `${id}-request-headers`, "taRequestHeaders", (current.request_headers || ["cookie", "authorization", "user-agent", "accept", "accept-language"]).join("\n"), "textarea", { rows: 3, maxLength: 8192 });
|
||||
const autheliaHeaders = ["remote-user", "remote-groups", "remote-name", "remote-email"];
|
||||
const authentikHeaders = ["x-authentik-username", "x-authentik-email", "x-authentik-name", "x-authentik-groups", "x-authentik-uid", "x-authentik-entitlements"];
|
||||
field(details, `${id}-response-headers`, "taResponseHeaders", (current.response_headers || [...autheliaHeaders, ...authentikHeaders]).join("\n"), "textarea", { rows: 4, maxLength: 8192 });
|
||||
field(details, `${id}-allowlist`, "vhAddressAllowlist", (current.address_allowlist || []).join("\n"), "textarea", { rows: 2, maxLength: 8192 });
|
||||
field(details, `${id}-ca`, "vhCa", current.tls_ca_pem || "", "textarea", { rows: 2, maxLength: 65536 });
|
||||
forwardEditors[id] = { body, url, login };
|
||||
}
|
||||
|
||||
basicBody.append(text("h3", "authBasic"));
|
||||
const basicGrid = el("div", undefined, "vh-grid"); basicBody.append(basicGrid);
|
||||
basicEditor = basicAuthEditor(basicGrid, value.basic_auth || {});
|
||||
const syncAuth = () => {
|
||||
authEnable.checked = authMode.value === "tinyauth";
|
||||
taBody.hidden = !authEnable.checked;
|
||||
const tinyauthSelected = authMode.value === "tinyauth";
|
||||
taBody.hidden = !tinyauthSelected;
|
||||
basicBody.hidden = authMode.value !== "basic";
|
||||
authUrl.required = loginUrl.required = authEnable.checked;
|
||||
authUrl.required = loginUrl.required = tinyauthSelected;
|
||||
for (const [id, editor] of Object.entries(forwardEditors)) {
|
||||
editor.body.hidden = id !== authMode.value;
|
||||
editor.url.required = id === authMode.value;
|
||||
if (editor.login) editor.login.required = id === authMode.value;
|
||||
}
|
||||
};
|
||||
authMode.addEventListener("change", syncAuth); syncAuth();
|
||||
g = group("vhResponsePolicy"); const server = value.server_header, diagnostics = value.diagnostic_headers;
|
||||
@@ -508,7 +554,9 @@ function collect() {
|
||||
content_type_policy: { enabled: yes("content-type-block-enabled"), blocked: lines(str("content-type-block-list")).map(value => value.toLowerCase()) },
|
||||
server_header: str("server-mode") === "inherit" ? null : { mode: str("server-mode"), value: str("server-value") },
|
||||
diagnostic_headers: yes("diag-inherit") ? null : { request_id: yes("diag-request_id") },
|
||||
tinyauth: { enabled: yes("auth-enabled"), url: str("auth-url"), login_url: str("auth-login"), allow_private: yes("auth-private"), redirect_forbidden: yes("auth-redirect-forbidden"), connect_timeout_seconds: num("auth-connect"), timeout_seconds: num("auth-timeout"), request_headers: lines(str("auth-request-headers")), response_headers: lines(str("auth-response-headers")), address_allowlist: lines(str("auth-allowlist")), tls_ca_pem: nullable("auth-ca") }, backends: members, routes: paths, acl, headers,
|
||||
tinyauth: { enabled: str("auth-mode") === "tinyauth", url: str("auth-url"), login_url: str("auth-login"), allow_private: yes("auth-private"), redirect_forbidden: yes("auth-redirect-forbidden"), connect_timeout_seconds: num("auth-connect"), timeout_seconds: num("auth-timeout"), request_headers: lines(str("auth-request-headers")), response_headers: lines(str("auth-response-headers")), address_allowlist: lines(str("auth-allowlist")), tls_ca_pem: nullable("auth-ca") },
|
||||
authelia: readForwardProvider("authelia"), authentik: readForwardProvider("authentik"),
|
||||
backends: members, routes: paths, acl, headers,
|
||||
load_balancing: {
|
||||
algorithm: str("algorithm"), slow_start_seconds: num("slow-start"), keepalive: { enabled: yes("keepalive"), idle_timeout_seconds: num("idle-timeout"), max_idle_per_host: num("max-idle"), tcp_keepalive_seconds: num("tcp-keepalive"), pool_recycle_requests: num("pool-recycle-requests"), pool_recycle_seconds: num("pool-recycle-seconds"), http1_max_buffer_bytes: num("http1-max-buffer") },
|
||||
retry: { max_retries: num("retry-max"), retry_connect_errors: yes("retry-connect"), retry_timeouts: yes("retry-timeouts"), retry_statuses: str("retry-statuses").split(/[\s,]+/).filter(Boolean).map(value => Number(value)) },
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// Shared, anchored Vhost popover for backend quick-view and upstream HTTP
|
||||
// protocol observations. Both consume existing live status without extra probes.
|
||||
let activeVhostBackendPopup = null;
|
||||
function closeVhostBackendPopup(returnFocus = false) {
|
||||
const current = activeVhostBackendPopup;
|
||||
@@ -390,6 +388,8 @@ function render() {
|
||||
const policy = el("div", undefined, "vhost-policy-pills");
|
||||
if (v.basic_auth?.enabled) policy.append(el("small", t("authBasic"), "pill"));
|
||||
if (v.tinyauth?.enabled) policy.append(el("small", "Tinyauth", "pill"));
|
||||
if (v.authelia?.enabled) policy.append(el("small", "Authelia", "pill"));
|
||||
if (v.authentik?.enabled) policy.append(el("small", "authentik", "pill"));
|
||||
if (v.rate_limit?.enabled) policy.append(el("small", t("vhRateEnabled"), "pill"));
|
||||
if (v.acl_profile_id) policy.append(el("small", `${t("vhAclProfile")}: ${v.acl_profile_id}`, "pill"));
|
||||
if (v.force_ssl) policy.append(el("small", t("vhForceSsl"), "pill"));
|
||||
@@ -573,7 +573,6 @@ form.addEventListener("submit", async event => {
|
||||
const value = collect(), next = structuredClone(envelope.config); next.vhosts ||= [];
|
||||
const debugIssues = debugOriginIssues(value.backends); if (debugIssues.length && !(await appConfirm(`${t("vhDebugOriginMismatch")}\n\n${debugIssues.map(member => member.id).join(", ")}`, { danger: true }))) return;
|
||||
const oldVhost = (envelope.config.vhosts || []).find(v => v.id === editing);
|
||||
// A previous acceptance must not silently authorize new hostnames or a different certificate.
|
||||
const certificateScope = item => JSON.stringify({
|
||||
certificate: item?.certificate_id || null,
|
||||
hosts: [...(item?.frontend?.hosts || [])].map(v => v.toLowerCase().replace(/\.$/, "")).sort(),
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use http::{header, HeaderValue, StatusCode};
|
||||
|
||||
use super::AuthenticationResponse;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum GatewayFailure {
|
||||
Connection,
|
||||
Timeout,
|
||||
Verification,
|
||||
}
|
||||
|
||||
pub fn deny(status: StatusCode) -> Response {
|
||||
let body = format!(
|
||||
"{} {}\n",
|
||||
status.as_u16(),
|
||||
status.canonical_reason().unwrap_or("Error")
|
||||
);
|
||||
let mut response = (status, body).into_response();
|
||||
response.headers_mut().insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("private, no-store"),
|
||||
);
|
||||
response.extensions_mut().insert(AuthenticationResponse);
|
||||
response
|
||||
}
|
||||
|
||||
fn escape_html(raw: &str) -> String {
|
||||
raw.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
.replace('{', "{")
|
||||
.replace('}', "}")
|
||||
}
|
||||
|
||||
pub fn gateway_failure(provider_name: &str, kind: GatewayFailure) -> Response {
|
||||
let (status, label, message) = match kind {
|
||||
GatewayFailure::Connection => (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"connection error",
|
||||
"Unable to connect to the",
|
||||
),
|
||||
GatewayFailure::Timeout => (StatusCode::GATEWAY_TIMEOUT, "timeout", "The"),
|
||||
GatewayFailure::Verification => (StatusCode::BAD_GATEWAY, "verification error", "The"),
|
||||
};
|
||||
|
||||
let name = escape_html(provider_name);
|
||||
let explanation = match kind {
|
||||
GatewayFailure::Connection => format!("{message} {name} authentication service."),
|
||||
GatewayFailure::Timeout => {
|
||||
format!("{message} {name} authentication service did not respond in time.")
|
||||
}
|
||||
GatewayFailure::Verification => {
|
||||
format!("{message} {name} authentication service returned an invalid response.")
|
||||
}
|
||||
};
|
||||
let html = include_str!("../admin/web/error_auth_provider.html")
|
||||
.replace("{{STATUS}}", &status.as_u16().to_string())
|
||||
.replace("{{PROVIDER}}", &name)
|
||||
.replace("{{ERROR}}", label)
|
||||
.replace("{{MESSAGE}}", &explanation);
|
||||
let mut response = (status, html).into_response();
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/html; charset=utf-8"),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("private, no-store"),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
"x-content-type-options",
|
||||
HeaderValue::from_static("nosniff"),
|
||||
);
|
||||
response.extensions_mut().insert(AuthenticationResponse);
|
||||
response
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use http_body_util::BodyExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn one_template_covers_every_provider_and_gateway_failure() {
|
||||
for name in ["Tinyauth", "Authelia", "authentik", "Future Provider"] {
|
||||
for (kind, status, description) in [
|
||||
(
|
||||
GatewayFailure::Connection,
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"connection error",
|
||||
),
|
||||
(
|
||||
GatewayFailure::Verification,
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"verification error",
|
||||
),
|
||||
(
|
||||
GatewayFailure::Timeout,
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
"timeout",
|
||||
),
|
||||
] {
|
||||
let response = gateway_failure(name, kind);
|
||||
assert_eq!(response.status(), status);
|
||||
assert_eq!(
|
||||
response.headers()[header::CONTENT_TYPE],
|
||||
"text/html; charset=utf-8"
|
||||
);
|
||||
assert_eq!(
|
||||
response.headers()[header::CACHE_CONTROL],
|
||||
"private, no-store"
|
||||
);
|
||||
assert_eq!(response.headers()["x-content-type-options"], "nosniff");
|
||||
assert!(response
|
||||
.extensions()
|
||||
.get::<AuthenticationResponse>()
|
||||
.is_some());
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let html = std::str::from_utf8(&body).unwrap();
|
||||
assert!(html.contains(&format!("{} - {name} {description}", status.as_u16())));
|
||||
assert!(
|
||||
!html.contains("{{")
|
||||
&& !html.contains("<script")
|
||||
&& !html.contains("127.0.0.1")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn provider_name_cannot_inject_html() {
|
||||
let response = gateway_failure("<script>alert(1)</script>", GatewayFailure::Connection);
|
||||
let html = String::from_utf8(
|
||||
response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap()
|
||||
.to_bytes()
|
||||
.to_vec(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(html.contains("<script>"));
|
||||
assert!(!html.contains("<script>"));
|
||||
}
|
||||
#[test]
|
||||
fn denied_responses_are_marked_and_not_cached() {
|
||||
let response = deny(StatusCode::UNAUTHORIZED);
|
||||
assert!(response
|
||||
.extensions()
|
||||
.get::<AuthenticationResponse>()
|
||||
.is_some());
|
||||
assert_eq!(
|
||||
response.headers()[header::CACHE_CONTROL],
|
||||
"private, no-store"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
use crate::{authelia, authentik, config::ValidationPolicy, forwarding, tinyauth};
|
||||
use anyhow::{ensure, Result};
|
||||
use axum::{body::Body, response::Response};
|
||||
use http::{header, HeaderMap, HeaderValue, Method, Request};
|
||||
use std::{
|
||||
future::Future,
|
||||
net::SocketAddr,
|
||||
pin::Pin,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
pub type Authorization<'a> =
|
||||
Pin<Box<dyn Future<Output = std::result::Result<Identity, Response>> + Send + 'a>>;
|
||||
pub type Route<'a> = Pin<Box<dyn Future<Output = Response> + Send + 'a>>;
|
||||
|
||||
mod errors;
|
||||
pub use errors::{deny, gateway_failure, GatewayFailure};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Identity {
|
||||
pub(crate) headers: HeaderMap,
|
||||
}
|
||||
impl Identity {
|
||||
pub fn apply(&self, headers: &mut HeaderMap) {
|
||||
for name in self.headers.keys() {
|
||||
headers.remove(name);
|
||||
}
|
||||
for (name, value) in &self.headers {
|
||||
headers.append(name.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait Provider: Send + Sync + std::fmt::Debug {
|
||||
fn id(&self) -> &'static str;
|
||||
fn strip_identity(&self, headers: &mut HeaderMap);
|
||||
fn authorize<'a>(
|
||||
&'a self,
|
||||
incoming: &'a HeaderMap,
|
||||
method: &'a Method,
|
||||
context: &'a forwarding::Context,
|
||||
shutdown: &'a CancellationToken,
|
||||
cookies: &'a CookieSink,
|
||||
) -> Authorization<'a>;
|
||||
|
||||
fn reserved_path(&self, _path: &str) -> bool {
|
||||
false
|
||||
}
|
||||
fn serve_reserved<'a>(
|
||||
&'a self,
|
||||
_request: Request<Body>,
|
||||
_context: &'a forwarding::Context,
|
||||
_shutdown: &'a CancellationToken,
|
||||
) -> Option<Route<'a>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Compiled(Box<dyn Provider>);
|
||||
impl Compiled {
|
||||
fn new<T: Provider + 'static>(provider: T) -> Self {
|
||||
Self(Box::new(provider))
|
||||
}
|
||||
pub fn id(&self) -> &'static str {
|
||||
self.0.id()
|
||||
}
|
||||
pub fn strip_identity(&self, headers: &mut HeaderMap) {
|
||||
self.0.strip_identity(headers);
|
||||
}
|
||||
pub fn reserved_path(&self, path: &str) -> bool {
|
||||
self.0.reserved_path(path)
|
||||
}
|
||||
pub fn serve_reserved<'a>(
|
||||
&'a self,
|
||||
request: Request<Body>,
|
||||
context: &'a forwarding::Context,
|
||||
shutdown: &'a CancellationToken,
|
||||
) -> Option<Route<'a>> {
|
||||
self.0.serve_reserved(request, context, shutdown)
|
||||
}
|
||||
pub async fn authorize(
|
||||
&self,
|
||||
incoming: &HeaderMap,
|
||||
method: &Method,
|
||||
context: &forwarding::Context,
|
||||
shutdown: &CancellationToken,
|
||||
cookies: &CookieSink,
|
||||
) -> std::result::Result<Identity, Response> {
|
||||
self.0
|
||||
.authorize(incoming, method, context, shutdown, cookies)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Selection<'a> {
|
||||
pub basic_enabled: bool,
|
||||
pub tinyauth: &'a tinyauth::Settings,
|
||||
pub authelia: &'a authelia::Settings,
|
||||
pub authentik: &'a authentik::Settings,
|
||||
}
|
||||
impl Selection<'_> {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
self.tinyauth.validate()?;
|
||||
self.authelia.validate()?;
|
||||
self.authentik.validate()?;
|
||||
let active = [
|
||||
self.basic_enabled,
|
||||
self.tinyauth.enabled,
|
||||
self.authelia.enabled,
|
||||
self.authentik.enabled,
|
||||
];
|
||||
ensure!(
|
||||
active.iter().filter(|enabled| **enabled).count() <= 1,
|
||||
"choose only one authentication provider (Basic, Tinyauth, Authelia or authentik)"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
pub fn compile(
|
||||
&self,
|
||||
policy: &ValidationPolicy,
|
||||
bindings: &[SocketAddr],
|
||||
) -> Result<Option<Compiled>> {
|
||||
self.validate()?;
|
||||
if self.tinyauth.enabled {
|
||||
return Ok(self.tinyauth.compile(policy, bindings)?.map(Compiled::new));
|
||||
}
|
||||
if self.authelia.enabled {
|
||||
return Ok(self.authelia.compile(policy, bindings)?.map(Compiled::new));
|
||||
}
|
||||
if self.authentik.enabled {
|
||||
return Ok(self.authentik.compile(policy, bindings)?.map(Compiled::new));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthenticationResponse;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct CookieSink(Arc<Mutex<Vec<HeaderValue>>>);
|
||||
impl CookieSink {
|
||||
pub(crate) fn collect(&self, headers: &HeaderMap) -> bool {
|
||||
let cookies: Vec<_> = headers
|
||||
.get_all(header::SET_COOKIE)
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect();
|
||||
let bytes: usize = cookies.iter().map(|value| value.as_bytes().len()).sum();
|
||||
if cookies.len() > 8 || bytes > 16384 {
|
||||
return false;
|
||||
}
|
||||
if !cookies.is_empty() {
|
||||
let mut store = self.0.lock().unwrap_or_else(|poison| poison.into_inner());
|
||||
if store.len() + cookies.len() > 8
|
||||
|| store.iter().map(|c| c.as_bytes().len()).sum::<usize>() + bytes > 16384
|
||||
{
|
||||
return false;
|
||||
}
|
||||
store.extend(cookies);
|
||||
}
|
||||
true
|
||||
}
|
||||
pub fn apply(&self, response: &mut Response) {
|
||||
let mut cookies = self.0.lock().unwrap_or_else(|poison| poison.into_inner());
|
||||
for cookie in cookies.drain(..) {
|
||||
response.headers_mut().append(header::SET_COOKIE, cookie);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod testing;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn legacy_vhost_keys_are_still_accepted_and_no_provider_is_enabled_by_default() {
|
||||
let host: crate::vhost::Vhost = serde_json::from_value(serde_json::json!({
|
||||
"id": "test", "frontend": {"hosts": ["app.example.com"], "listener_ids": ["https"]}
|
||||
}))
|
||||
.unwrap();
|
||||
let selection = Selection {
|
||||
basic_enabled: host.basic_auth.enabled,
|
||||
tinyauth: &host.tinyauth,
|
||||
authelia: &host.authelia,
|
||||
authentik: &host.authentik,
|
||||
};
|
||||
assert!(selection.validate().is_ok());
|
||||
}
|
||||
#[test]
|
||||
fn rejects_multiple_active_providers_before_compilation() {
|
||||
let mut tinyauth = tinyauth::Settings::default();
|
||||
let mut authelia = authelia::Settings::default();
|
||||
let authentik = authentik::Settings::default();
|
||||
tinyauth.enabled = true;
|
||||
authelia.enabled = true;
|
||||
let selection = Selection {
|
||||
basic_enabled: false,
|
||||
tinyauth: &tinyauth,
|
||||
authelia: &authelia,
|
||||
authentik: &authentik,
|
||||
};
|
||||
assert!(selection.validate().is_err());
|
||||
}
|
||||
#[derive(Debug)]
|
||||
struct FutureProvider;
|
||||
impl Provider for FutureProvider {
|
||||
fn id(&self) -> &'static str {
|
||||
"future"
|
||||
}
|
||||
fn strip_identity(&self, _headers: &mut HeaderMap) {}
|
||||
fn authorize<'a>(
|
||||
&'a self,
|
||||
_incoming: &'a HeaderMap,
|
||||
_method: &'a Method,
|
||||
_context: &'a forwarding::Context,
|
||||
_shutdown: &'a CancellationToken,
|
||||
_cookies: &'a CookieSink,
|
||||
) -> Authorization<'a> {
|
||||
Box::pin(async { Err(deny(http::StatusCode::UNAUTHORIZED)) })
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn future_providers_can_use_runtime_interface_without_proxy_branches() {
|
||||
let provider = Compiled::new(FutureProvider);
|
||||
assert_eq!(provider.id(), "future");
|
||||
assert!(!provider.reserved_path("/"));
|
||||
let headers = HeaderMap::new();
|
||||
let response = provider
|
||||
.authorize(
|
||||
&headers,
|
||||
&Method::GET,
|
||||
&testing::context(&headers, "app.example.com"),
|
||||
&CancellationToken::new(),
|
||||
&CookieSink::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(response.status(), http::StatusCode::UNAUTHORIZED);
|
||||
assert!(response
|
||||
.extensions()
|
||||
.get::<AuthenticationResponse>()
|
||||
.is_some());
|
||||
}
|
||||
#[test]
|
||||
fn cookie_sink_is_request_scoped_and_bounded() {
|
||||
let first = CookieSink::default();
|
||||
let second = CookieSink::default();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.append(header::SET_COOKIE, "session=ok; Path=/".parse().unwrap());
|
||||
assert!(first.collect(&headers));
|
||||
let mut resp = Response::new(Body::empty());
|
||||
second.apply(&mut resp);
|
||||
assert!(resp.headers().get(header::SET_COOKIE).is_none());
|
||||
first.apply(&mut resp);
|
||||
assert_eq!(resp.headers()[header::SET_COOKIE], "session=ok; Path=/");
|
||||
assert_eq!(first.0.lock().unwrap().len(), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
use crate::{config::ValidationPolicy, forwarding};
|
||||
use http::HeaderMap;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
pub(crate) fn policy() -> ValidationPolicy {
|
||||
ValidationPolicy {
|
||||
proxy_addr: "127.0.0.1:8080".parse().unwrap(),
|
||||
admin_addr: "127.0.0.1:9090".parse().unwrap(),
|
||||
tls_addr: None,
|
||||
cert_dir: None,
|
||||
data_dir: None,
|
||||
}
|
||||
}
|
||||
pub(crate) fn context(incoming: &HeaderMap, host: &str) -> forwarding::Context {
|
||||
forwarding::Settings::default()
|
||||
.compile()
|
||||
.unwrap()
|
||||
.context(
|
||||
incoming,
|
||||
"192.0.2.20:34567".parse().unwrap(),
|
||||
"10.0.0.1:443".parse().unwrap(),
|
||||
true,
|
||||
&host.parse().unwrap(),
|
||||
&"/private?redirect=%2Fhome".parse().unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
pub(crate) async fn spawn_mock(
|
||||
status: &str,
|
||||
response_headers: &str,
|
||||
) -> (String, tokio::task::JoinHandle<String>) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n{response_headers}\r\n"
|
||||
);
|
||||
let task = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let mut bytes = Vec::new();
|
||||
let mut chunk = [0u8; 4096];
|
||||
loop {
|
||||
let n = stream.read(&mut chunk).await.unwrap();
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
bytes.extend_from_slice(&chunk[..n]);
|
||||
if bytes.windows(4).any(|value| value == b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
stream.write_all(response.as_bytes()).await.unwrap();
|
||||
String::from_utf8_lossy(&bytes).into_owned()
|
||||
});
|
||||
(format!("http://{address}"), task)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
use crate::{
|
||||
auth_provider::{self, CookieSink},
|
||||
config::ValidationPolicy,
|
||||
forward_auth, forwarding,
|
||||
};
|
||||
use anyhow::{ensure, Result};
|
||||
use axum::response::Response;
|
||||
use http::{header, HeaderMap, Method, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
net::SocketAddr,
|
||||
ops::{Deref, DerefMut},
|
||||
};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Settings(forward_auth::Settings);
|
||||
impl Deref for Settings {
|
||||
type Target = forward_auth::Settings;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
impl DerefMut for Settings {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
impl Settings {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
self.0.validate::<Adapter>()
|
||||
}
|
||||
pub fn compile(
|
||||
&self,
|
||||
policy: &ValidationPolicy,
|
||||
bindings: &[SocketAddr],
|
||||
) -> Result<Option<Compiled>> {
|
||||
Ok(self.0.compile::<Adapter>(policy, bindings)?.map(Compiled))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Compiled(forward_auth::Compiled<Adapter>);
|
||||
#[derive(Debug)]
|
||||
pub struct Adapter;
|
||||
impl forward_auth::Adapter for Adapter {
|
||||
fn label() -> &'static str {
|
||||
"Authelia"
|
||||
}
|
||||
fn verification_path() -> &'static str {
|
||||
"/api/authz/forward-auth"
|
||||
}
|
||||
fn portal(settings: &forward_auth::Settings) -> Result<Option<Url>> {
|
||||
let portal = forward_auth::http_url(&settings.login_url, "Authelia login")?;
|
||||
ensure!(
|
||||
forward_auth::is_portal_https(&portal),
|
||||
"public Authelia login must use HTTPS"
|
||||
);
|
||||
Ok(Some(portal))
|
||||
}
|
||||
fn denied(
|
||||
status: StatusCode,
|
||||
response_headers: &HeaderMap,
|
||||
_original_url: &str,
|
||||
portal: Option<&Url>,
|
||||
redirect_forbidden: bool,
|
||||
) -> std::result::Result<Response, Response> {
|
||||
if matches!(
|
||||
status,
|
||||
StatusCode::FOUND | StatusCode::SEE_OTHER | StatusCode::UNAUTHORIZED
|
||||
) || (status == StatusCode::FORBIDDEN && redirect_forbidden)
|
||||
{
|
||||
let location = portal.and_then(|url| {
|
||||
forward_auth::login_redirect(response_headers.get(header::LOCATION), url)
|
||||
});
|
||||
if let Some(location) = location {
|
||||
let mut response = forward_auth::fail(if status == StatusCode::SEE_OTHER {
|
||||
StatusCode::SEE_OTHER
|
||||
} else {
|
||||
StatusCode::FOUND
|
||||
});
|
||||
response.headers_mut().insert(header::LOCATION, location);
|
||||
Ok(response)
|
||||
} else if matches!(status, StatusCode::FOUND | StatusCode::SEE_OTHER) {
|
||||
Err(forward_auth::invalid(
|
||||
"Authelia",
|
||||
"Authelia returned an untrusted login redirect",
|
||||
))
|
||||
} else {
|
||||
Ok(forward_auth::fail(status))
|
||||
}
|
||||
} else if status == StatusCode::FORBIDDEN {
|
||||
Ok(forward_auth::fail(status))
|
||||
} else {
|
||||
Err(forward_auth::invalid(
|
||||
"Authelia",
|
||||
"Authelia returned an unexpected verifier status",
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
impl auth_provider::Provider for Compiled {
|
||||
fn id(&self) -> &'static str {
|
||||
"authelia"
|
||||
}
|
||||
fn strip_identity(&self, headers: &mut HeaderMap) {
|
||||
self.0.strip_identity(headers);
|
||||
}
|
||||
fn authorize<'a>(
|
||||
&'a self,
|
||||
incoming: &'a HeaderMap,
|
||||
method: &'a Method,
|
||||
context: &'a forwarding::Context,
|
||||
shutdown: &'a CancellationToken,
|
||||
cookies: &'a CookieSink,
|
||||
) -> auth_provider::Authorization<'a> {
|
||||
Box::pin(
|
||||
self.0
|
||||
.authorize(incoming, method, context, shutdown, cookies),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
include!("tests.rs");
|
||||
@@ -0,0 +1,85 @@
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::auth_provider::{Provider, testing::{context, policy, spawn_mock}};
|
||||
use http_body_util::BodyExt;
|
||||
|
||||
fn settings(url: &str) -> Settings {
|
||||
let mut settings = Settings::default();
|
||||
settings.enabled = true;
|
||||
settings.url = url.into();
|
||||
settings.login_url = "https://auth.example.com".into();
|
||||
settings.allow_private = true;
|
||||
settings
|
||||
}
|
||||
fn compile(url: &str) -> Compiled { settings(url).compile(&policy(), &[]).unwrap().unwrap() }
|
||||
|
||||
#[test]
|
||||
fn legacy_json_shape_and_provider_identity_are_preserved() {
|
||||
let parsed: Settings = serde_json::from_str(r#"{"enabled":true,"url":"http://127.0.0.1:9300","login_url":"https://auth.example.com","allow_private":true}"#).unwrap();
|
||||
assert!(parsed.enabled);
|
||||
let serialized = serde_json::to_value(&parsed).unwrap();
|
||||
assert_eq!(serialized["url"], "http://127.0.0.1:9300");
|
||||
assert!(serialized.get("0").is_none());
|
||||
assert_eq!(compile("http://127.0.0.1:9300").id(), "authelia");
|
||||
}
|
||||
#[test]
|
||||
fn invalid_portal_and_cross_origin_redirects_are_rejected() {
|
||||
let mut s = settings("http://127.0.0.1:9300");
|
||||
s.login_url = "http://auth.example.com".into();
|
||||
assert!(s.validate().is_err());
|
||||
let portal = Url::parse("https://auth.example.com/").unwrap();
|
||||
for bad in ["https://evil.example/", "//evil.example", "https://auth.example.com@evil.example/", "javascript:alert(1)", "https://auth.example.com/#frag"] {
|
||||
assert!(forward_auth::login_redirect(Some(&bad.parse().unwrap()), &portal).is_none(), "{bad}");
|
||||
}
|
||||
assert_eq!(forward_auth::login_redirect(Some(&"/login?rd=1".parse().unwrap()), &portal).unwrap(), "https://auth.example.com/login?rd=1");
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn checks_forward_auth_endpoint_and_returns_only_verified_identity() {
|
||||
let (url, server) = spawn_mock("200 OK", "Remote-User: trusted\r\nRemote-Email: trusted@example.com\r\n").await;
|
||||
let auth = compile(&url);
|
||||
let mut incoming = HeaderMap::new();
|
||||
incoming.insert("remote-user", "forged".parse().unwrap());
|
||||
let identity = auth.authorize(&incoming, &Method::GET, &context(&incoming, "app.example.com"),
|
||||
&CancellationToken::new(), &CookieSink::default()).await.unwrap();
|
||||
let request = server.await.unwrap().to_ascii_lowercase();
|
||||
assert!(request.contains("get /api/authz/forward-auth"));
|
||||
assert!(request.contains("x-forwarded-host: app.example.com"));
|
||||
assert!(request.contains("x-forwarded-uri: /private?redirect=%2fhome"));
|
||||
assert!(!request.contains("remote-user: forged"));
|
||||
let mut headers = HeaderMap::new();
|
||||
identity.apply(&mut headers);
|
||||
assert_eq!(headers["remote-user"], "trusted");
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn redirects_only_to_trusted_portal_and_denies_untrusted_redirects() {
|
||||
let (url, server) = spawn_mock("302 Found", "Location: https://auth.example.com/?rd=home\r\n").await;
|
||||
let auth = compile(&url);
|
||||
let incoming = HeaderMap::new();
|
||||
let response = auth.authorize(&incoming, &Method::GET, &context(&incoming, "app.example.com"),
|
||||
&CancellationToken::new(), &CookieSink::default()).await.unwrap_err();
|
||||
assert_eq!(response.status(), StatusCode::FOUND);
|
||||
assert!(response.headers()[header::LOCATION].to_str().unwrap().starts_with("https://auth.example.com/"));
|
||||
server.await.unwrap();
|
||||
|
||||
let (url, server) = spawn_mock("302 Found", "Location: https://evil.example/\r\n").await;
|
||||
let auth = compile(&url);
|
||||
let response = auth.authorize(&incoming, &Method::GET, &context(&incoming, "app.example.com"),
|
||||
&CancellationToken::new(), &CookieSink::default()).await.unwrap_err();
|
||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
server.await.unwrap();
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn unreachable_provider_fails_closed_with_no_origin_details() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let url = format!("http://{}", listener.local_addr().unwrap());
|
||||
drop(listener);
|
||||
let auth = compile(&url);
|
||||
let incoming = HeaderMap::new();
|
||||
let response = auth.authorize(&incoming, &Method::GET, &context(&incoming, "app.example.com"),
|
||||
&CancellationToken::new(), &CookieSink::default()).await.unwrap_err();
|
||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
assert!(response.extensions().get::<auth_provider::AuthenticationResponse>().is_some());
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert!(!body.windows(url.len()).any(|chunk| chunk == url.as_bytes()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
use crate::{
|
||||
auth_provider::{self, CookieSink},
|
||||
config::ValidationPolicy,
|
||||
forward_auth, forwarding,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use axum::{body::Body, response::Response};
|
||||
use http::{header, HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
net::SocketAddr,
|
||||
ops::{Deref, DerefMut},
|
||||
};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use url::Url;
|
||||
|
||||
const OUTPOST_PREFIX: &str = "/outpost.goauthentik.io";
|
||||
pub fn is_outpost_path(path: &str) -> bool {
|
||||
path == OUTPOST_PREFIX || path.starts_with("/outpost.goauthentik.io/")
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Settings(forward_auth::Settings);
|
||||
impl Deref for Settings {
|
||||
type Target = forward_auth::Settings;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
impl DerefMut for Settings {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
impl Settings {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
self.0.validate::<Adapter>()
|
||||
}
|
||||
pub fn compile(
|
||||
&self,
|
||||
policy: &ValidationPolicy,
|
||||
bindings: &[SocketAddr],
|
||||
) -> Result<Option<Compiled>> {
|
||||
Ok(self.0.compile::<Adapter>(policy, bindings)?.map(Compiled))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Compiled(forward_auth::Compiled<Adapter>);
|
||||
#[derive(Debug)]
|
||||
pub struct Adapter;
|
||||
impl forward_auth::Adapter for Adapter {
|
||||
fn label() -> &'static str {
|
||||
"authentik"
|
||||
}
|
||||
fn verification_path() -> &'static str {
|
||||
"/outpost.goauthentik.io/auth/nginx"
|
||||
}
|
||||
fn denied(
|
||||
status: StatusCode,
|
||||
_response_headers: &HeaderMap,
|
||||
original_url: &str,
|
||||
_portal: Option<&Url>,
|
||||
_redirect_forbidden: bool,
|
||||
) -> std::result::Result<Response, Response> {
|
||||
if status == StatusCode::UNAUTHORIZED {
|
||||
let mut location = Url::parse(original_url).map_err(|_| {
|
||||
forward_auth::invalid("authentik", "invalid original URL for authentik")
|
||||
})?;
|
||||
location.set_path("/outpost.goauthentik.io/start");
|
||||
location.set_query(None);
|
||||
location.query_pairs_mut().append_pair("rd", original_url);
|
||||
let mut response = forward_auth::fail(StatusCode::FOUND);
|
||||
response.headers_mut().insert(
|
||||
header::LOCATION,
|
||||
HeaderValue::from_str(location.as_str()).map_err(|_| {
|
||||
forward_auth::invalid("authentik", "invalid authentik redirect")
|
||||
})?,
|
||||
);
|
||||
Ok(response)
|
||||
} else if status == StatusCode::FORBIDDEN {
|
||||
Ok(forward_auth::fail(status))
|
||||
} else {
|
||||
Err(forward_auth::invalid(
|
||||
"authentik",
|
||||
"authentik returned an unexpected verifier status",
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
impl auth_provider::Provider for Compiled {
|
||||
fn id(&self) -> &'static str {
|
||||
"authentik"
|
||||
}
|
||||
fn strip_identity(&self, headers: &mut HeaderMap) {
|
||||
self.0.strip_identity(headers);
|
||||
}
|
||||
fn authorize<'a>(
|
||||
&'a self,
|
||||
incoming: &'a HeaderMap,
|
||||
method: &'a Method,
|
||||
context: &'a forwarding::Context,
|
||||
shutdown: &'a CancellationToken,
|
||||
cookies: &'a CookieSink,
|
||||
) -> auth_provider::Authorization<'a> {
|
||||
Box::pin(
|
||||
self.0
|
||||
.authorize(incoming, method, context, shutdown, cookies),
|
||||
)
|
||||
}
|
||||
fn reserved_path(&self, path: &str) -> bool {
|
||||
is_outpost_path(path)
|
||||
}
|
||||
fn serve_reserved<'a>(
|
||||
&'a self,
|
||||
request: Request<Body>,
|
||||
context: &'a forwarding::Context,
|
||||
shutdown: &'a CancellationToken,
|
||||
) -> Option<auth_provider::Route<'a>> {
|
||||
if !is_outpost_path(request.uri().path()) {
|
||||
return None;
|
||||
}
|
||||
Some(Box::pin(self.proxy_outpost(request, context, shutdown)))
|
||||
}
|
||||
}
|
||||
impl Compiled {
|
||||
async fn proxy_outpost(
|
||||
&self,
|
||||
incoming: Request<Body>,
|
||||
context: &forwarding::Context,
|
||||
shutdown: &CancellationToken,
|
||||
) -> Response {
|
||||
if !is_outpost_path(incoming.uri().path()) {
|
||||
return forward_auth::fail(StatusCode::NOT_FOUND);
|
||||
}
|
||||
if incoming.method() != Method::GET
|
||||
&& incoming.method() != Method::HEAD
|
||||
&& incoming.method() != Method::POST
|
||||
{
|
||||
return forward_auth::fail(StatusCode::METHOD_NOT_ALLOWED);
|
||||
}
|
||||
let (parts, body) = incoming.into_parts();
|
||||
let suffix = parts
|
||||
.uri
|
||||
.path_and_query()
|
||||
.map(|value| value.as_str())
|
||||
.unwrap_or(OUTPOST_PREFIX);
|
||||
let uri: Uri = match format!("{}{}", self.0.backend.request_origin, suffix).parse() {
|
||||
Ok(uri) => uri,
|
||||
Err(_) => return forward_auth::fail(StatusCode::BAD_REQUEST),
|
||||
};
|
||||
let mut outbound = match Request::builder()
|
||||
.method(parts.method.clone())
|
||||
.uri(uri)
|
||||
.body(body)
|
||||
{
|
||||
Ok(request) => request,
|
||||
Err(_) => return forward_auth::fail(StatusCode::BAD_REQUEST),
|
||||
};
|
||||
*outbound.headers_mut() = parts.headers;
|
||||
crate::proxy::headers::strip_hop_by_hop(outbound.headers_mut());
|
||||
crate::proxy::headers::strip_admin_cookie(outbound.headers_mut());
|
||||
self.0.strip_identity(outbound.headers_mut());
|
||||
if forward_auth::auth_context(outbound.headers_mut(), &parts.method, context).is_err() {
|
||||
return forward_auth::fail(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
let original_host = match Url::parse(&context.original_url) {
|
||||
Ok(url) => url,
|
||||
Err(_) => return forward_auth::fail(StatusCode::BAD_REQUEST),
|
||||
};
|
||||
let host = match original_host.port() {
|
||||
Some(port) => format!("{}:{port}", original_host.host_str().unwrap_or("")),
|
||||
None => original_host.host_str().unwrap_or("").to_owned(),
|
||||
};
|
||||
if host.is_empty() {
|
||||
return forward_auth::fail(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
if let Ok(host) = host.parse::<HeaderValue>() {
|
||||
outbound.headers_mut().insert(header::HOST, host);
|
||||
} else {
|
||||
return forward_auth::fail(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
let checked = tokio::select! {
|
||||
_ = shutdown.cancelled() => return forward_auth::fail(StatusCode::SERVICE_UNAVAILABLE),
|
||||
result = tokio::time::timeout(self.0.timeout, self.0.backend.client.request(outbound)) => result,
|
||||
};
|
||||
let response = match checked {
|
||||
Ok(Ok(response)) => response,
|
||||
Ok(Err(error)) => {
|
||||
tracing::warn!(%error, "authentik outpost connection failed");
|
||||
return auth_provider::gateway_failure(
|
||||
"authentik",
|
||||
auth_provider::GatewayFailure::Connection,
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
return auth_provider::gateway_failure(
|
||||
"authentik",
|
||||
auth_provider::GatewayFailure::Timeout,
|
||||
)
|
||||
}
|
||||
};
|
||||
let (parts, body) = response.into_parts();
|
||||
let mut response = Response::new(Body::new(body));
|
||||
*response.status_mut() = parts.status;
|
||||
*response.headers_mut() = parts.headers;
|
||||
crate::proxy::headers::strip_hop_by_hop(response.headers_mut());
|
||||
response.headers_mut().insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("private, no-store"),
|
||||
);
|
||||
response
|
||||
.extensions_mut()
|
||||
.insert(auth_provider::AuthenticationResponse);
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
include!("tests.rs");
|
||||
@@ -0,0 +1,176 @@
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::auth_provider::{
|
||||
testing::{context, policy, spawn_mock},
|
||||
Provider,
|
||||
};
|
||||
|
||||
fn settings(url: &str) -> Settings {
|
||||
let mut settings = Settings::default();
|
||||
settings.enabled = true;
|
||||
settings.url = url.into();
|
||||
settings.allow_private = true;
|
||||
settings
|
||||
}
|
||||
fn compile(url: &str) -> Compiled {
|
||||
settings(url).compile(&policy(), &[]).unwrap().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_json_and_provider_path_contract_are_preserved() {
|
||||
let parsed: Settings = serde_json::from_str(
|
||||
r#"{"enabled":true,"url":"http://127.0.0.1:9300","allow_private":true}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(&parsed).unwrap()["url"],
|
||||
"http://127.0.0.1:9300"
|
||||
);
|
||||
let auth = compile("http://127.0.0.1:9300");
|
||||
assert_eq!(auth.id(), "authentik");
|
||||
assert!(auth.reserved_path("/outpost.goauthentik.io"));
|
||||
assert!(auth.reserved_path("/outpost.goauthentik.io/start"));
|
||||
assert!(!auth.reserved_path("/outpost.goauthentik.io-evil"));
|
||||
assert!(!auth.reserved_path("/other/outpost.goauthentik.io"));
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn unauthorized_redirect_uses_application_domain_not_portal_domain() {
|
||||
let (url, server) = spawn_mock(
|
||||
"401 Unauthorized",
|
||||
"Set-Cookie: authentik_session=abc; Secure; Path=/; HttpOnly\r\n",
|
||||
)
|
||||
.await;
|
||||
let auth = compile(&url);
|
||||
let incoming = HeaderMap::new();
|
||||
let response = auth
|
||||
.authorize(
|
||||
&incoming,
|
||||
&Method::POST,
|
||||
&context(&incoming, "app.other-domain.example"),
|
||||
&CancellationToken::new(),
|
||||
&CookieSink::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(response.status(), StatusCode::FOUND);
|
||||
let location = Url::parse(response.headers()[header::LOCATION].to_str().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
location.origin().ascii_serialization(),
|
||||
"https://app.other-domain.example"
|
||||
);
|
||||
assert_eq!(location.path(), "/outpost.goauthentik.io/start");
|
||||
assert_eq!(
|
||||
location.query_pairs().find(|(k, _)| k == "rd").unwrap().1,
|
||||
"https://app.other-domain.example/private?redirect=%2Fhome"
|
||||
);
|
||||
assert_eq!(
|
||||
response.headers()[header::SET_COOKIE],
|
||||
"authentik_session=abc; Secure; Path=/; HttpOnly"
|
||||
);
|
||||
server.await.unwrap();
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn forbidden_response_never_authorizes_backend() {
|
||||
let (url, server) = spawn_mock("403 Forbidden", "").await;
|
||||
let auth = compile(&url);
|
||||
let incoming = HeaderMap::new();
|
||||
let response = auth
|
||||
.authorize(
|
||||
&incoming,
|
||||
&Method::GET,
|
||||
&context(&incoming, "app.example.com"),
|
||||
&CancellationToken::new(),
|
||||
&CookieSink::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
server.await.unwrap();
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn verified_headers_and_refresh_cookie_are_forwarded() {
|
||||
let (url, server) = spawn_mock("200 OK", "X-Authentik-Username: allowed\r\nSet-Cookie: authentik_session=renew; Path=/; HttpOnly\r\n").await;
|
||||
let auth = compile(&url);
|
||||
let incoming = HeaderMap::new();
|
||||
let cookies = CookieSink::default();
|
||||
let identity = auth
|
||||
.authorize(
|
||||
&incoming,
|
||||
&Method::GET,
|
||||
&context(&incoming, "app.example.com"),
|
||||
&CancellationToken::new(),
|
||||
&cookies,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut headers = HeaderMap::new();
|
||||
identity.apply(&mut headers);
|
||||
assert_eq!(headers["x-authentik-username"], "allowed");
|
||||
let mut response = Response::new(Body::empty());
|
||||
cookies.apply(&mut response);
|
||||
assert_eq!(
|
||||
response.headers()[header::SET_COOKIE],
|
||||
"authentik_session=renew; Path=/; HttpOnly"
|
||||
);
|
||||
server.await.unwrap();
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn outpost_is_proxied_only_on_reserved_path_with_trusted_host() {
|
||||
let (url, server) = spawn_mock("302 Found", "Location: https://auth.example.com/if/flow/default/\r\nSet-Cookie: authentik=ok; Path=/\r\n").await;
|
||||
let auth = compile(&url);
|
||||
let incoming = HeaderMap::new();
|
||||
let request = Request::builder()
|
||||
.uri("/outpost.goauthentik.io/start?rd=https%3A%2F%2Fapp.example.com")
|
||||
.header("X-Forwarded-Host", "spoofed.example")
|
||||
.header("X-Authentik-Username", "forged")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let response = auth
|
||||
.serve_reserved(
|
||||
request,
|
||||
&context(&incoming, "app.example.com"),
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.unwrap()
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::FOUND);
|
||||
assert_eq!(
|
||||
response.headers()[header::SET_COOKIE],
|
||||
"authentik=ok; Path=/"
|
||||
);
|
||||
assert_eq!(
|
||||
response.headers()[header::CACHE_CONTROL],
|
||||
"private, no-store"
|
||||
);
|
||||
let request = server.await.unwrap().to_ascii_lowercase();
|
||||
assert!(request.starts_with("get /outpost.goauthentik.io/start?rd="));
|
||||
assert!(request.contains("host: app.example.com"));
|
||||
assert!(request.contains("x-forwarded-host: app.example.com"));
|
||||
assert!(!request.contains("spoofed.example"));
|
||||
assert!(!request.contains("x-authentik-username: forged"));
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn auth_check_uses_provider_specific_endpoint() {
|
||||
let (url, server) = spawn_mock("200 OK", "X-Authentik-Username: admin\r\n").await;
|
||||
let auth = compile(&url);
|
||||
let incoming = HeaderMap::new();
|
||||
let identity = auth
|
||||
.authorize(
|
||||
&incoming,
|
||||
&Method::GET,
|
||||
&context(&incoming, "app.example.com"),
|
||||
&CancellationToken::new(),
|
||||
&CookieSink::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut headers = HeaderMap::new();
|
||||
identity.apply(&mut headers);
|
||||
assert_eq!(headers["x-authentik-username"], "admin");
|
||||
assert!(server
|
||||
.await
|
||||
.unwrap()
|
||||
.to_ascii_lowercase()
|
||||
.contains("get /outpost.goauthentik.io/auth/nginx"));
|
||||
}
|
||||
}
|
||||
@@ -154,7 +154,7 @@ fn denied(status: StatusCode, challenge: Option<&HeaderValue>) -> Response {
|
||||
}
|
||||
response
|
||||
.extensions_mut()
|
||||
.insert(crate::tinyauth::AuthenticationFailure);
|
||||
.insert(crate::auth_provider::AuthenticationResponse);
|
||||
response
|
||||
}
|
||||
fn credentials(headers: &HeaderMap) -> Option<(String, Zeroizing<String>, HeaderValue)> {
|
||||
|
||||
+1
-6
@@ -70,8 +70,6 @@ pub struct Config {
|
||||
pub vhosts: Vec<crate::vhost::Vhost>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub host_policies: Vec<HostPolicy>,
|
||||
// Preserve old 0.34.13 and earlier config/backup data until compilation migrates it.
|
||||
// Migration happens in compile_with_active, before validation and persistence.
|
||||
#[serde(
|
||||
default,
|
||||
rename = "user_agent_blocking",
|
||||
@@ -85,12 +83,9 @@ pub struct Config {
|
||||
#[serde(default, skip_serializing_if = "HeaderRules::is_empty")]
|
||||
pub headers: HeaderRules,
|
||||
}
|
||||
/// Global HTTP public-listener security. Disabled defaults preserve older configurations.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct GlobalSecurity {
|
||||
// None distinguishes omitted legacy input from an explicitly configured empty policy.
|
||||
// Successful compilation always normalizes this to Some(...).
|
||||
pub user_agent_blocking: Option<crate::user_agent::Policy>,
|
||||
pub country_access: crate::rate_limit::CountryAccess,
|
||||
pub acl: Option<Acl>,
|
||||
@@ -308,7 +303,7 @@ pub struct CompiledFrontend {
|
||||
pub metrics: Arc<crate::metrics::VhostMetrics>,
|
||||
pub server_header: crate::settings::ServerHeader,
|
||||
pub diagnostic_headers: crate::settings::DiagnosticHeaders,
|
||||
pub tinyauth: Option<crate::tinyauth::Compiled>,
|
||||
pub auth_provider: Option<crate::auth_provider::Compiled>,
|
||||
pub id: String,
|
||||
pub bindings: Vec<crate::vhost::Listener>,
|
||||
pub acl_profile_id: Option<String>,
|
||||
|
||||
@@ -216,7 +216,13 @@ fn compile_routing(
|
||||
.diagnostic_headers
|
||||
.clone()
|
||||
.unwrap_or_else(|| config.diagnostic_headers.clone()),
|
||||
tinyauth: host.tinyauth.compile(&desired_policy, &guarded_bindings)?,
|
||||
auth_provider: crate::auth_provider::Selection {
|
||||
basic_enabled: host.basic_auth.enabled,
|
||||
tinyauth: &host.tinyauth,
|
||||
authelia: &host.authelia,
|
||||
authentik: &host.authentik,
|
||||
}
|
||||
.compile(&desired_policy, &guarded_bindings)?,
|
||||
id: host.id.clone(),
|
||||
bindings: host_bindings,
|
||||
acl_profile_id: host.acl_profile_id.clone(),
|
||||
|
||||
@@ -28,6 +28,43 @@ mod tests {
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_forward_auth_providers_preserve_legacy_vhost_configuration() {
|
||||
let config = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
let encoded = serde_json::to_value(&config.vhosts[0]).unwrap();
|
||||
let mut legacy = encoded.as_object().unwrap().clone();
|
||||
legacy.remove("authelia");
|
||||
legacy.remove("authentik");
|
||||
let restored: crate::vhost::Vhost = serde_json::from_value(legacy.into()).unwrap();
|
||||
assert!(!restored.authelia.enabled);
|
||||
assert!(!restored.authentik.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forward_auth_selection_must_be_mutually_exclusive() {
|
||||
for (left, right) in [
|
||||
("tinyauth", "authelia"),
|
||||
("authelia", "authentik"),
|
||||
("authentik", "basic"),
|
||||
] {
|
||||
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
let host = &mut config.vhosts[0];
|
||||
for enabled in [left, right] {
|
||||
match enabled {
|
||||
"tinyauth" => host.tinyauth.enabled = true,
|
||||
"authelia" => host.authelia.enabled = true,
|
||||
"authentik" => host.authentik.enabled = true,
|
||||
"basic" => host.basic_auth.enabled = true,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
host.validate().is_err(),
|
||||
"combination {left} and {right} must be refused"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_http2_settings_use_release_defaults() {
|
||||
let config: Config = serde_json::from_value(serde_json::json!({})).unwrap();
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
use crate::{
|
||||
auth_provider::{self, CookieSink, GatewayFailure, Identity},
|
||||
config::{Backend, CompiledBackend, UpstreamProtocol, ValidationPolicy},
|
||||
forwarding,
|
||||
};
|
||||
use anyhow::{ensure, Context as _, Result};
|
||||
use axum::{body::Body, response::Response};
|
||||
use http::{header, HeaderMap, HeaderName, HeaderValue, Method, Request, StatusCode, Uri};
|
||||
use http_body_util::{BodyExt, Limited};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{marker::PhantomData, net::SocketAddr, sync::Arc, time::Duration};
|
||||
use url::Url;
|
||||
|
||||
pub trait Adapter: Send + Sync + std::fmt::Debug + 'static {
|
||||
fn label() -> &'static str;
|
||||
fn verification_path() -> &'static str;
|
||||
fn portal(_settings: &Settings) -> Result<Option<Url>> {
|
||||
Ok(None)
|
||||
}
|
||||
fn denied(
|
||||
status: StatusCode,
|
||||
response_headers: &HeaderMap,
|
||||
original_url: &str,
|
||||
portal: Option<&Url>,
|
||||
redirect_forbidden: bool,
|
||||
) -> std::result::Result<Response, Response>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Settings {
|
||||
pub enabled: bool,
|
||||
pub url: String,
|
||||
pub login_url: String,
|
||||
pub allow_private: bool,
|
||||
pub address_allowlist: Vec<String>,
|
||||
pub tls_ca_pem: Option<String>,
|
||||
pub connect_timeout_seconds: u64,
|
||||
pub timeout_seconds: u64,
|
||||
pub request_headers: Vec<String>,
|
||||
pub response_headers: Vec<String>,
|
||||
pub redirect_forbidden: bool,
|
||||
}
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
url: String::new(),
|
||||
login_url: String::new(),
|
||||
allow_private: false,
|
||||
address_allowlist: vec![],
|
||||
tls_ca_pem: None,
|
||||
connect_timeout_seconds: 5,
|
||||
timeout_seconds: 10,
|
||||
request_headers: vec![
|
||||
"cookie".into(),
|
||||
"authorization".into(),
|
||||
"user-agent".into(),
|
||||
"accept".into(),
|
||||
"accept-language".into(),
|
||||
],
|
||||
response_headers: vec![
|
||||
"remote-user".into(),
|
||||
"remote-groups".into(),
|
||||
"remote-name".into(),
|
||||
"remote-email".into(),
|
||||
"x-authentik-username".into(),
|
||||
"x-authentik-email".into(),
|
||||
"x-authentik-name".into(),
|
||||
"x-authentik-groups".into(),
|
||||
"x-authentik-uid".into(),
|
||||
"x-authentik-entitlements".into(),
|
||||
],
|
||||
redirect_forbidden: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub struct Compiled<A: Adapter> {
|
||||
pub(crate) backend: Arc<CompiledBackend>,
|
||||
check_uri: Uri,
|
||||
portal: Option<Url>,
|
||||
pub(crate) timeout: Duration,
|
||||
request_headers: Vec<HeaderName>,
|
||||
response_headers: Vec<HeaderName>,
|
||||
_adapter: PhantomData<A>,
|
||||
redirect_forbidden: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn fail(status: StatusCode) -> Response {
|
||||
auth_provider::deny(status)
|
||||
}
|
||||
pub(crate) fn invalid(provider_name: &'static str, message: &str) -> Response {
|
||||
tracing::warn!(provider = provider_name, "{message}");
|
||||
auth_provider::gateway_failure(provider_name, GatewayFailure::Verification)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum CheckError {
|
||||
Connection(anyhow::Error),
|
||||
InvalidResponse(anyhow::Error),
|
||||
}
|
||||
pub(crate) fn http_url(value: &str, label: &str) -> Result<Url> {
|
||||
ensure!(
|
||||
(1..=2048).contains(&value.len()),
|
||||
"{label} URL must be 1..2048 bytes"
|
||||
);
|
||||
let url = Url::parse(value)?;
|
||||
ensure!(
|
||||
matches!(url.scheme(), "http" | "https") && url.host_str().is_some(),
|
||||
"{label} URL must use HTTP(S)"
|
||||
);
|
||||
ensure!(
|
||||
url.username().is_empty() && url.password().is_none() && url.fragment().is_none(),
|
||||
"{label} URL cannot contain credentials or fragments"
|
||||
);
|
||||
Ok(url)
|
||||
}
|
||||
fn identity_name(name: &HeaderName) -> bool {
|
||||
matches!(
|
||||
name.as_str(),
|
||||
"remote-user"
|
||||
| "remote-email"
|
||||
| "remote-name"
|
||||
| "remote-groups"
|
||||
| "remote-sub"
|
||||
| "x-auth-request-user"
|
||||
| "x-auth-request-email"
|
||||
| "x-auth-request-groups"
|
||||
| "x-auth-request-name"
|
||||
) || name.as_str().starts_with("x-authentik-")
|
||||
}
|
||||
pub(crate) fn is_portal_https(url: &Url) -> bool {
|
||||
url.scheme() == "https"
|
||||
|| url.host_str().is_some_and(|host| {
|
||||
host == "localhost"
|
||||
|| host
|
||||
.parse::<std::net::IpAddr>()
|
||||
.is_ok_and(|ip| ip.is_loopback())
|
||||
})
|
||||
}
|
||||
impl Settings {
|
||||
pub fn validate<A: Adapter>(&self) -> Result<()> {
|
||||
ensure!(
|
||||
(1..=30).contains(&self.connect_timeout_seconds)
|
||||
&& (1..=60).contains(&self.timeout_seconds)
|
||||
&& self.connect_timeout_seconds <= self.timeout_seconds,
|
||||
"{} requires connect timeout <= total timeout (1..30s / 1..60s)",
|
||||
A::label()
|
||||
);
|
||||
ensure!(
|
||||
self.request_headers.len() <= 32 && self.response_headers.len() <= 32,
|
||||
"{} allows at most 32 headers per direction",
|
||||
A::label()
|
||||
);
|
||||
let mut names = std::collections::HashSet::new();
|
||||
for raw in &self.request_headers {
|
||||
ensure!(raw.len() <= 128, "{} request header too long", A::label());
|
||||
let name: HeaderName = raw.parse()?;
|
||||
ensure!(
|
||||
matches!(
|
||||
name.as_str(),
|
||||
"cookie"
|
||||
| "authorization"
|
||||
| "user-agent"
|
||||
| "accept"
|
||||
| "accept-language"
|
||||
| "x-api-key"
|
||||
),
|
||||
"unsafe {} request header: {name}",
|
||||
A::label()
|
||||
);
|
||||
}
|
||||
for raw in &self.response_headers {
|
||||
ensure!(raw.len() <= 128, "{} response header too long", A::label());
|
||||
let name: HeaderName = raw.parse()?;
|
||||
ensure!(
|
||||
identity_name(&name) && names.insert(name),
|
||||
"unsafe or duplicate {} identity header",
|
||||
A::label()
|
||||
);
|
||||
}
|
||||
if self.enabled {
|
||||
let url = http_url(&self.url, A::label())?;
|
||||
ensure!(
|
||||
url.query().is_none()
|
||||
&& (url.path() == "/" || url.path() == A::verification_path()),
|
||||
"{} verifier must be an origin or the documented auth endpoint",
|
||||
A::label()
|
||||
);
|
||||
A::portal(self)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn compile<A: Adapter>(
|
||||
&self,
|
||||
policy: &ValidationPolicy,
|
||||
bindings: &[SocketAddr],
|
||||
) -> Result<Option<Compiled<A>>> {
|
||||
self.validate::<A>()?;
|
||||
if !self.enabled {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut endpoint = http_url(&self.url, A::label())?;
|
||||
endpoint.set_path(A::verification_path());
|
||||
let origin = endpoint.origin().ascii_serialization();
|
||||
let definition = Backend {
|
||||
id: A::label().to_lowercase(),
|
||||
url: origin,
|
||||
allow_private: self.allow_private,
|
||||
accept_local_listener_risk: false,
|
||||
preserve_host: false,
|
||||
protocol: UpstreamProtocol::Http1,
|
||||
address_allowlist: self.address_allowlist.clone(),
|
||||
tls_ca_pem: self.tls_ca_pem.clone(),
|
||||
tls_server_name: None,
|
||||
tls_verify: true,
|
||||
};
|
||||
let keepalive = crate::balance::Keepalive::default();
|
||||
let mut backend =
|
||||
crate::config::compile_backend(&definition, policy, bindings, &keepalive)?;
|
||||
backend.client = crate::upstream::client_with_timeout(
|
||||
&definition,
|
||||
backend.address_policy.clone(),
|
||||
false,
|
||||
&keepalive,
|
||||
Duration::from_secs(self.connect_timeout_seconds),
|
||||
)?;
|
||||
Ok(Some(Compiled {
|
||||
backend: Arc::new(backend),
|
||||
check_uri: endpoint.as_str().parse()?,
|
||||
portal: A::portal(self)?,
|
||||
timeout: Duration::from_secs(self.timeout_seconds),
|
||||
request_headers: self
|
||||
.request_headers
|
||||
.iter()
|
||||
.map(|value| value.parse())
|
||||
.collect::<std::result::Result<_, _>>()?,
|
||||
response_headers: self
|
||||
.response_headers
|
||||
.iter()
|
||||
.map(|value| value.parse())
|
||||
.collect::<std::result::Result<_, _>>()?,
|
||||
redirect_forbidden: self.redirect_forbidden,
|
||||
_adapter: PhantomData,
|
||||
}))
|
||||
}
|
||||
}
|
||||
pub(crate) fn auth_context(
|
||||
headers: &mut HeaderMap,
|
||||
method: &Method,
|
||||
context: &forwarding::Context,
|
||||
) -> Result<()> {
|
||||
// Never trust inbound original metadata, including markers from another auth module.
|
||||
for name in headers
|
||||
.keys()
|
||||
.filter(|name| name.as_str().starts_with("x-original-"))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
{
|
||||
headers.remove(name);
|
||||
}
|
||||
context.apply(headers);
|
||||
headers.insert("x-original-url", context.original_url.parse()?);
|
||||
headers.insert("x-original-method", method.as_str().parse()?);
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn login_redirect(raw: Option<&HeaderValue>, portal: &Url) -> Option<HeaderValue> {
|
||||
let raw = raw?.to_str().ok()?;
|
||||
if raw.len() > 8192 {
|
||||
return None;
|
||||
}
|
||||
let url = if raw.starts_with('/') && !raw.starts_with("//") {
|
||||
portal.join(raw).ok()?
|
||||
} else {
|
||||
Url::parse(raw).ok()?
|
||||
};
|
||||
if url.origin() != portal.origin()
|
||||
|| !url.username().is_empty()
|
||||
|| url.password().is_some()
|
||||
|| url.fragment().is_some()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
url.as_str().parse().ok()
|
||||
}
|
||||
impl<A: Adapter> Compiled<A> {
|
||||
pub fn strip_identity(&self, headers: &mut HeaderMap) {
|
||||
for name in headers
|
||||
.keys()
|
||||
.filter(|name| identity_name(name))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
{
|
||||
headers.remove(name);
|
||||
}
|
||||
for name in &self.response_headers {
|
||||
headers.remove(name);
|
||||
}
|
||||
}
|
||||
pub async fn authorize(
|
||||
&self,
|
||||
incoming: &HeaderMap,
|
||||
method: &Method,
|
||||
context: &forwarding::Context,
|
||||
shutdown: &tokio_util::sync::CancellationToken,
|
||||
cookies: &CookieSink,
|
||||
) -> std::result::Result<Identity, Response> {
|
||||
let check = tokio::select! {
|
||||
_ = shutdown.cancelled() => return Err(fail(StatusCode::SERVICE_UNAVAILABLE)),
|
||||
result = tokio::time::timeout(self.timeout, self.check(incoming, method, context)) => result,
|
||||
};
|
||||
let (status, response_headers) = match check {
|
||||
Ok(Ok(check)) => check,
|
||||
Ok(Err(CheckError::Connection(error))) => {
|
||||
tracing::warn!(provider = A::label(), %error, "forward-auth connection failed");
|
||||
return Err(auth_provider::gateway_failure(
|
||||
A::label(),
|
||||
GatewayFailure::Connection,
|
||||
));
|
||||
}
|
||||
Ok(Err(CheckError::InvalidResponse(error))) => {
|
||||
tracing::warn!(provider = A::label(), %error, "forward-auth response invalid");
|
||||
return Err(auth_provider::gateway_failure(
|
||||
A::label(),
|
||||
GatewayFailure::Verification,
|
||||
));
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!(provider = A::label(), "forward-auth verifier timed out");
|
||||
return Err(auth_provider::gateway_failure(
|
||||
A::label(),
|
||||
GatewayFailure::Timeout,
|
||||
));
|
||||
}
|
||||
};
|
||||
if status == StatusCode::OK {
|
||||
let mut identity = HeaderMap::new();
|
||||
let mut size = 0usize;
|
||||
for name in &self.response_headers {
|
||||
for value in response_headers.get_all(name) {
|
||||
size += value.as_bytes().len();
|
||||
if size > 8192 {
|
||||
return Err(invalid(
|
||||
A::label(),
|
||||
"forward-auth identity headers too large",
|
||||
));
|
||||
}
|
||||
identity.append(name.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
if !cookies.collect(&response_headers) {
|
||||
return Err(invalid(
|
||||
A::label(),
|
||||
"forward-auth session headers too large",
|
||||
));
|
||||
}
|
||||
return Ok(Identity { headers: identity });
|
||||
}
|
||||
let mut response = A::denied(
|
||||
status,
|
||||
&response_headers,
|
||||
&context.original_url,
|
||||
self.portal.as_ref(),
|
||||
self.redirect_forbidden,
|
||||
)?;
|
||||
let session = CookieSink::default();
|
||||
if !session.collect(&response_headers) {
|
||||
return Err(invalid(
|
||||
A::label(),
|
||||
"forward-auth response cookies too large",
|
||||
));
|
||||
}
|
||||
session.apply(&mut response);
|
||||
Err(response)
|
||||
}
|
||||
async fn check(
|
||||
&self,
|
||||
incoming: &HeaderMap,
|
||||
method: &Method,
|
||||
context: &forwarding::Context,
|
||||
) -> std::result::Result<(StatusCode, HeaderMap), CheckError> {
|
||||
let mut request = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(self.check_uri.clone())
|
||||
.body(Body::empty())
|
||||
.map_err(|error| CheckError::InvalidResponse(error.into()))?;
|
||||
for name in &self.request_headers {
|
||||
for value in incoming.get_all(name) {
|
||||
request.headers_mut().append(name.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
crate::proxy::headers::strip_hop_by_hop(request.headers_mut());
|
||||
crate::proxy::headers::strip_admin_cookie(request.headers_mut());
|
||||
self.strip_identity(request.headers_mut());
|
||||
auth_context(request.headers_mut(), method, context)
|
||||
.map_err(CheckError::InvalidResponse)?;
|
||||
request
|
||||
.headers_mut()
|
||||
.insert(header::HOST, self.backend.host_header.clone());
|
||||
let response = self
|
||||
.backend
|
||||
.client
|
||||
.request(request)
|
||||
.await
|
||||
.context("forward-auth connection")
|
||||
.map_err(CheckError::Connection)?;
|
||||
let (parts, body) = response.into_parts();
|
||||
Limited::new(body, 16 * 1024)
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("forward-auth verification body: {error}"))
|
||||
.map_err(CheckError::InvalidResponse)?;
|
||||
Ok((parts.status, parts.headers))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
include!("tests.rs");
|
||||
@@ -0,0 +1,251 @@
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{authelia, authentik};
|
||||
|
||||
#[tokio::test]
|
||||
async fn all_forward_auth_adapters_use_generic_gateway_pages() {
|
||||
use crate::auth_provider::testing::{context, policy, spawn_mock};
|
||||
use http_body_util::BodyExt;
|
||||
|
||||
let incoming = HeaderMap::new();
|
||||
for provider in ["Authelia", "authentik"] {
|
||||
let (url, server) = spawn_mock("503 Service Unavailable", "").await;
|
||||
let settings = Settings {
|
||||
enabled: true,
|
||||
url,
|
||||
login_url: "https://auth.example.com".into(),
|
||||
allow_private: true,
|
||||
..Default::default()
|
||||
};
|
||||
let result = if provider == "Authelia" {
|
||||
settings
|
||||
.compile::<authelia::Adapter>(&policy(), &[])
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.authorize(
|
||||
&incoming,
|
||||
&Method::GET,
|
||||
&context(&incoming, "app.example.com"),
|
||||
&tokio_util::sync::CancellationToken::new(),
|
||||
&CookieSink::default(),
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
settings
|
||||
.compile::<authentik::Adapter>(&policy(), &[])
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.authorize(
|
||||
&incoming,
|
||||
&Method::GET,
|
||||
&context(&incoming, "app.example.com"),
|
||||
&tokio_util::sync::CancellationToken::new(),
|
||||
&CookieSink::default(),
|
||||
)
|
||||
.await
|
||||
};
|
||||
let response = result.unwrap_err();
|
||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
let html = String::from_utf8(
|
||||
response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap()
|
||||
.to_bytes()
|
||||
.to_vec(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(html.contains(&format!("{provider} verification error")));
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let url = format!("http://{}", listener.local_addr().unwrap());
|
||||
drop(listener);
|
||||
let settings = Settings {
|
||||
enabled: true,
|
||||
url,
|
||||
login_url: "https://auth.example.com".into(),
|
||||
allow_private: true,
|
||||
..Default::default()
|
||||
};
|
||||
let context = context(&incoming, "app.example.com");
|
||||
let shutdown = tokio_util::sync::CancellationToken::new();
|
||||
let cookies = CookieSink::default();
|
||||
let result = settings
|
||||
.compile::<authelia::Adapter>(&policy(), &[])
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.authorize(&incoming, &Method::GET, &context, &shutdown, &cookies)
|
||||
.await;
|
||||
let response = result.unwrap_err();
|
||||
let html = String::from_utf8(
|
||||
response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap()
|
||||
.to_bytes()
|
||||
.to_vec(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(html.contains("Authelia connection error"));
|
||||
let result = settings
|
||||
.compile::<authentik::Adapter>(&policy(), &[])
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.authorize(&incoming, &Method::GET, &context, &shutdown, &cookies)
|
||||
.await;
|
||||
let response = result.unwrap_err();
|
||||
let html = String::from_utf8(
|
||||
response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap()
|
||||
.to_bytes()
|
||||
.to_vec(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(html.contains("authentik connection error"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn verifier_deadline_produces_generic_504_page_for_both_adapters() {
|
||||
use crate::auth_provider::testing::{context, policy};
|
||||
use http_body_util::BodyExt;
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let settings = Settings {
|
||||
enabled: true,
|
||||
url: format!("http://{}", listener.local_addr().unwrap()),
|
||||
login_url: "https://auth.example.com".into(),
|
||||
allow_private: true,
|
||||
connect_timeout_seconds: 1,
|
||||
timeout_seconds: 1,
|
||||
..Default::default()
|
||||
};
|
||||
let incoming = HeaderMap::new();
|
||||
let ctx = context(&incoming, "app.example.com");
|
||||
let shutdown = tokio_util::sync::CancellationToken::new();
|
||||
let cookies = CookieSink::default();
|
||||
let response = settings
|
||||
.compile::<authelia::Adapter>(&policy(), &[])
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.authorize(&incoming, &Method::GET, &ctx, &shutdown, &cookies)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
|
||||
let html = String::from_utf8(
|
||||
response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap()
|
||||
.to_bytes()
|
||||
.to_vec(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(html.contains("Authelia timeout"));
|
||||
let response = settings
|
||||
.compile::<authentik::Adapter>(&policy(), &[])
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.authorize(&incoming, &Method::GET, &ctx, &shutdown, &cookies)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
|
||||
let html = String::from_utf8(
|
||||
response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap()
|
||||
.to_bytes()
|
||||
.to_vec(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(html.contains("authentik timeout"));
|
||||
drop(listener);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_forward_auth_configs_are_backward_compatible() {
|
||||
for empty in ["{}", "{\"enabled\":false}"] {
|
||||
let settings: Settings = serde_json::from_str(empty).unwrap();
|
||||
assert!(!settings.enabled);
|
||||
assert!(settings.validate::<authelia::Adapter>().is_ok());
|
||||
assert!(settings.validate::<authentik::Adapter>().is_ok());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn provider_verification_endpoint_and_header_safety_are_checked() {
|
||||
for provider in ["authelia", "authentik"] {
|
||||
let mut settings = Settings {
|
||||
enabled: true,
|
||||
url: "http://127.0.0.1:9300".into(),
|
||||
login_url: "https://auth.example.com".into(),
|
||||
allow_private: true,
|
||||
..Default::default()
|
||||
};
|
||||
let validate = |settings: &Settings| -> Result<()> {
|
||||
if provider == "authelia" {
|
||||
settings.validate::<authelia::Adapter>()
|
||||
} else {
|
||||
settings.validate::<authentik::Adapter>()
|
||||
}
|
||||
};
|
||||
assert!(validate(&settings).is_ok());
|
||||
for bad in [
|
||||
"file:///etc/passwd",
|
||||
"http://user:password@127.0.0.1:9300",
|
||||
"http://127.0.0.1:9300/other",
|
||||
"http://127.0.0.1:9300?q=1",
|
||||
"https://example/#frag",
|
||||
] {
|
||||
settings.url = bad.into();
|
||||
assert!(validate(&settings).is_err(), "{provider}: {bad}");
|
||||
}
|
||||
settings.url = "http://127.0.0.1:9300".into();
|
||||
settings.request_headers.push("x-forwarded-host".into());
|
||||
assert!(validate(&settings).is_err());
|
||||
settings.request_headers.pop();
|
||||
settings.response_headers.push("authorization".into());
|
||||
assert!(validate(&settings).is_err());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn original_request_headers_are_regenerated_and_identity_is_sanitized() {
|
||||
use crate::auth_provider::testing::{context, policy};
|
||||
let settings = Settings {
|
||||
enabled: true,
|
||||
url: "http://127.0.0.1:9300".into(),
|
||||
allow_private: true,
|
||||
..Default::default()
|
||||
};
|
||||
let compiled = settings
|
||||
.compile::<authentik::Adapter>(&policy(), &[])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("remote-user", "forged".parse().unwrap());
|
||||
headers.insert("x-authentik-username", "forged".parse().unwrap());
|
||||
headers.insert(
|
||||
"x-original-url",
|
||||
"https://evil.example/private".parse().unwrap(),
|
||||
);
|
||||
headers.insert("x-forwarded-host", "evil.example".parse().unwrap());
|
||||
let ctx = context(&headers, "app.example.com");
|
||||
compiled.strip_identity(&mut headers);
|
||||
auth_context(&mut headers, &Method::POST, &ctx).unwrap();
|
||||
assert_eq!(
|
||||
headers["x-original-url"],
|
||||
"https://app.example.com/private?redirect=%2Fhome"
|
||||
);
|
||||
assert_eq!(headers["x-forwarded-host"], "app.example.com");
|
||||
assert_eq!(headers["x-original-method"], "POST");
|
||||
assert!(!headers.contains_key("remote-user"));
|
||||
assert!(!headers.contains_key("x-authentik-username"));
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,11 @@ pub mod stream;
|
||||
pub mod system_stats;
|
||||
pub mod transform;
|
||||
|
||||
pub mod auth_provider;
|
||||
pub mod authelia;
|
||||
pub mod authentik;
|
||||
pub mod balance;
|
||||
pub mod forward_auth;
|
||||
pub mod forwarding;
|
||||
pub mod geoip;
|
||||
pub mod http3;
|
||||
|
||||
@@ -267,7 +267,6 @@ fn finish_headers(
|
||||
request_id: &str,
|
||||
active: &[crate::vhost::Listener],
|
||||
) {
|
||||
// Global security headers form a baseline; explicitly set vhost values take precedence.
|
||||
snapshot
|
||||
.global_security
|
||||
.headers
|
||||
@@ -283,7 +282,7 @@ fn finish_headers(
|
||||
.map(|f| &f.diagnostic_headers)
|
||||
.unwrap_or(&snapshot.envelope.config.diagnostic_headers)
|
||||
.apply(response.headers_mut(), request_id);
|
||||
if frontend.is_some_and(|f| f.tinyauth.is_some() || f.basic_auth.is_some()) {
|
||||
if frontend.is_some_and(|f| f.auth_provider.is_some() || f.basic_auth.is_some()) {
|
||||
response.headers_mut().insert(
|
||||
"cache-control",
|
||||
HeaderValue::from_static("private, no-store"),
|
||||
|
||||
@@ -26,6 +26,8 @@ fn disabled_cache_retains_reason_without_matching_force_rules_or_reparsing_heade
|
||||
"admin_control_plane",
|
||||
"basic_auth",
|
||||
"tinyauth",
|
||||
"authelia",
|
||||
"authentik",
|
||||
"sticky_cookie",
|
||||
"request_upgrade",
|
||||
] {
|
||||
|
||||
@@ -127,8 +127,8 @@ let policy_reason = if admin_control_plane {
|
||||
Some("admin_control_plane")
|
||||
} else if frontend.is_some_and(|f| f.basic_auth.is_some()) {
|
||||
Some("basic_auth")
|
||||
} else if frontend.is_some_and(|f| f.tinyauth.is_some()) {
|
||||
Some("tinyauth")
|
||||
} else if let Some(provider) = frontend.and_then(|f| f.auth_provider.as_ref()) {
|
||||
Some(provider.id())
|
||||
} else if route
|
||||
.pool
|
||||
.as_ref()
|
||||
@@ -244,7 +244,7 @@ context.apply_upstream(
|
||||
.as_ref()
|
||||
.is_none_or(|frontend| frontend.forward_client_ip),
|
||||
);
|
||||
if let Some(auth) = frontend.and_then(|f| f.tinyauth.as_ref()) {
|
||||
if let Some(auth) = frontend.and_then(|f| f.auth_provider.as_ref()) {
|
||||
auth.strip_identity(request.headers_mut());
|
||||
}
|
||||
if let Some(identity) = identity {
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
let transform_settings = route.frontend.as_ref().map(|frontend| &frontend.transform);
|
||||
let scope = route.frontend.as_ref().map(|frontend| frontend.id.as_str()).unwrap_or("");
|
||||
let enabled = cache_settings.active_for(route.cache)
|
||||
&& frontend.is_none_or(|frontend| frontend.basic_auth.is_none() && frontend.tinyauth.is_none());
|
||||
&& frontend.is_none_or(|frontend| frontend.basic_auth.is_none() && frontend.auth_provider.is_none());
|
||||
if let Some(exchange) = request.extensions().get::<Arc<Exchange>>() {
|
||||
exchange.set_cache_enabled(enabled);
|
||||
exchange.set_transform_enabled(transform_settings.is_some_and(|settings| settings.enabled));
|
||||
|
||||
@@ -2,9 +2,13 @@ async fn forward(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(peer): Extension<PeerAddr>,
|
||||
Extension(local): Extension<LocalAddr>,
|
||||
request: Request,
|
||||
mut request: Request,
|
||||
) -> Response {
|
||||
forward_request(state, peer, local, request).await
|
||||
let cookies = crate::auth_provider::CookieSink::default();
|
||||
request.extensions_mut().insert(cookies.clone());
|
||||
let mut response = forward_request(state, peer, local, request).await;
|
||||
cookies.apply(&mut response);
|
||||
response
|
||||
}
|
||||
|
||||
async fn forward_request(
|
||||
@@ -197,7 +201,7 @@ async fn forward_request(
|
||||
tls,
|
||||
&original_host,
|
||||
request.uri(),
|
||||
frontend.is_some_and(|frontend| frontend.tinyauth.is_some()),
|
||||
frontend.is_some_and(|frontend| frontend.auth_provider.is_some()),
|
||||
request
|
||||
.extensions()
|
||||
.get::<Arc<crate::forwarding::PeerFormatting>>()
|
||||
@@ -464,6 +468,18 @@ async fn forward_request(
|
||||
.filter(|route| route.error_fallback.is_some())
|
||||
.map(|_| ErrorFallbackRequest::capture(&request, &snapshot));
|
||||
let admin_cookie = headers::strip_admin_cookie(request.headers_mut());
|
||||
|
||||
if let Some(auth) = frontend.and_then(|frontend| frontend.auth_provider.as_ref()) {
|
||||
if auth.reserved_path(&path) {
|
||||
return match auth.serve_reserved(request, &context, &state.shutdown) {
|
||||
Some(response) => response.await,
|
||||
None => error(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"RustProxy: authentication route unavailable\n",
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
let basic_identity = if let Some(auth) = frontend.and_then(|f| f.basic_auth.as_ref()) {
|
||||
match auth
|
||||
.authorize(
|
||||
@@ -487,14 +503,20 @@ async fn forward_request(
|
||||
"RustProxy: WebSocket access is disabled for this host\n",
|
||||
);
|
||||
}
|
||||
let identity = if let Some(auth) = frontend.and_then(|f| f.tinyauth.as_ref()) {
|
||||
let identity = if let Some(auth) = frontend.and_then(|f| f.auth_provider.as_ref()) {
|
||||
auth.strip_identity(request.headers_mut());
|
||||
let cookies = request
|
||||
.extensions()
|
||||
.get::<crate::auth_provider::CookieSink>()
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
match auth
|
||||
.authorize(
|
||||
request.headers(),
|
||||
request.method(),
|
||||
&context,
|
||||
&state.shutdown,
|
||||
&cookies,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -37,7 +37,7 @@ fn should_replace_error_page(response: &Response, minimal_welcome: bool) -> bool
|
||||
.is_none()
|
||||
&& response
|
||||
.extensions()
|
||||
.get::<crate::tinyauth::AuthenticationFailure>()
|
||||
.get::<crate::auth_provider::AuthenticationResponse>()
|
||||
.is_none()
|
||||
}
|
||||
|
||||
@@ -285,7 +285,7 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
|
||||
.is_none()
|
||||
&& response
|
||||
.extensions()
|
||||
.get::<crate::tinyauth::AuthenticationFailure>()
|
||||
.get::<crate::auth_provider::AuthenticationResponse>()
|
||||
.is_none()
|
||||
{
|
||||
apply_response_rules(
|
||||
@@ -385,7 +385,7 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
|
||||
.is_none()
|
||||
&& response
|
||||
.extensions()
|
||||
.get::<crate::tinyauth::AuthenticationFailure>()
|
||||
.get::<crate::auth_provider::AuthenticationResponse>()
|
||||
.is_none()
|
||||
{
|
||||
let outcome = match exchange.cache_status.load(Ordering::Relaxed) {
|
||||
@@ -513,3 +513,21 @@ fn effective_host(request: &Request, tls: bool) -> Result<HeaderValue, ()> {
|
||||
request.headers().get(HOST).cloned().ok_or(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod auth_provider_page_interception_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn provider_failures_and_redirects_bypass_custom_http_error_pages() {
|
||||
for kind in [
|
||||
crate::auth_provider::GatewayFailure::Connection,
|
||||
crate::auth_provider::GatewayFailure::Timeout,
|
||||
crate::auth_provider::GatewayFailure::Verification,
|
||||
] {
|
||||
let response = crate::auth_provider::gateway_failure("Future", kind);
|
||||
assert!(!should_replace_error_page(&response, false));
|
||||
}
|
||||
let response = crate::auth_provider::deny(StatusCode::UNAUTHORIZED);
|
||||
assert!(!should_replace_error_page(&response, false));
|
||||
}
|
||||
}
|
||||
@@ -144,6 +144,8 @@ fn vhost_cache_revision(vhost: &crate::vhost::Vhost) -> u64 {
|
||||
view.content_type_policy = Default::default();
|
||||
view.basic_auth = Default::default();
|
||||
view.tinyauth = Default::default();
|
||||
view.authelia = Default::default();
|
||||
view.authentik = Default::default();
|
||||
view.acl = None;
|
||||
view.acl_profile_id = None;
|
||||
view.user_agent_blocking = Default::default();
|
||||
|
||||
+44
-76
@@ -1,12 +1,10 @@
|
||||
use crate::{
|
||||
auth_provider::{self, GatewayFailure, Identity},
|
||||
config::{Backend, CompiledBackend, UpstreamProtocol, ValidationPolicy},
|
||||
forwarding,
|
||||
};
|
||||
use anyhow::{ensure, Context as _, Result};
|
||||
use axum::{
|
||||
body::Body,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use axum::{body::Body, response::Response};
|
||||
use http::{HeaderMap, HeaderName, HeaderValue, Method, Request, StatusCode, Uri};
|
||||
use http_body_util::{BodyExt, Limited};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -61,57 +59,12 @@ pub struct Compiled {
|
||||
response_headers: Vec<HeaderName>,
|
||||
redirect_forbidden: bool,
|
||||
}
|
||||
#[derive(Clone)]
|
||||
pub struct AuthenticationFailure;
|
||||
|
||||
#[derive(Debug)]
|
||||
enum CheckError {
|
||||
Connection(anyhow::Error),
|
||||
InvalidResponse(anyhow::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum GatewayFailure {
|
||||
Connection,
|
||||
Timeout,
|
||||
Verification,
|
||||
}
|
||||
|
||||
fn gateway_failure(kind: GatewayFailure) -> Response {
|
||||
let (status, html) = match kind {
|
||||
GatewayFailure::Connection => (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
include_str!("../admin/web/error_tinyauth_connection.html"),
|
||||
),
|
||||
GatewayFailure::Timeout => (
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
include_str!("../admin/web/error_tinyauth_timeout.html"),
|
||||
),
|
||||
GatewayFailure::Verification => (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
include_str!("../admin/web/error_tinyauth_verification.html"),
|
||||
),
|
||||
};
|
||||
let mut response = (status, html).into_response();
|
||||
response.headers_mut().insert(
|
||||
"content-type",
|
||||
HeaderValue::from_static("text/html; charset=utf-8"),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
"cache-control",
|
||||
HeaderValue::from_static("private, no-store"),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
"x-content-type-options",
|
||||
HeaderValue::from_static("nosniff"),
|
||||
);
|
||||
response.extensions_mut().insert(AuthenticationFailure);
|
||||
response
|
||||
}
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Identity {
|
||||
headers: HeaderMap,
|
||||
}
|
||||
const STANDARD_IDENTITY: [&str; 6] = [
|
||||
"remote-user",
|
||||
"remote-email",
|
||||
@@ -252,18 +205,7 @@ impl Settings {
|
||||
}
|
||||
}
|
||||
fn failure(status: StatusCode) -> Response {
|
||||
let body = format!(
|
||||
"{} {}\n",
|
||||
status.as_u16(),
|
||||
status.canonical_reason().unwrap_or("Error")
|
||||
);
|
||||
let mut response = (status, body).into_response();
|
||||
response.headers_mut().insert(
|
||||
"cache-control",
|
||||
HeaderValue::from_static("private, no-store"),
|
||||
);
|
||||
response.extensions_mut().insert(AuthenticationFailure);
|
||||
response
|
||||
auth_provider::deny(status)
|
||||
}
|
||||
|
||||
fn apply_nginx_auth_context(
|
||||
@@ -362,15 +304,24 @@ impl Compiled {
|
||||
Ok(Ok(value)) => value,
|
||||
Ok(Err(CheckError::Connection(error))) => {
|
||||
tracing::warn!(%error, "Tinyauth backend connection failed");
|
||||
return Err(gateway_failure(GatewayFailure::Connection));
|
||||
return Err(auth_provider::gateway_failure(
|
||||
"Tinyauth",
|
||||
GatewayFailure::Connection,
|
||||
));
|
||||
}
|
||||
Ok(Err(CheckError::InvalidResponse(error))) => {
|
||||
tracing::warn!(%error, "Tinyauth returned an invalid verification response");
|
||||
return Err(gateway_failure(GatewayFailure::Verification));
|
||||
return Err(auth_provider::gateway_failure(
|
||||
"Tinyauth",
|
||||
GatewayFailure::Verification,
|
||||
));
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!("Tinyauth verification timed out");
|
||||
return Err(gateway_failure(GatewayFailure::Timeout));
|
||||
return Err(auth_provider::gateway_failure(
|
||||
"Tinyauth",
|
||||
GatewayFailure::Timeout,
|
||||
));
|
||||
}
|
||||
};
|
||||
if status.is_success() {
|
||||
@@ -381,7 +332,10 @@ impl Compiled {
|
||||
size += value.as_bytes().len();
|
||||
if size > 8192 {
|
||||
tracing::warn!("Tinyauth identity headers exceed the configured limit");
|
||||
return Err(gateway_failure(GatewayFailure::Verification));
|
||||
return Err(auth_provider::gateway_failure(
|
||||
"Tinyauth",
|
||||
GatewayFailure::Verification,
|
||||
));
|
||||
}
|
||||
identity.headers.append(name.clone(), value.clone());
|
||||
}
|
||||
@@ -408,7 +362,10 @@ impl Compiled {
|
||||
status = status.as_u16(),
|
||||
"Tinyauth returned an unexpected HTTP status"
|
||||
);
|
||||
Err(gateway_failure(GatewayFailure::Verification))
|
||||
Err(auth_provider::gateway_failure(
|
||||
"Tinyauth",
|
||||
GatewayFailure::Verification,
|
||||
))
|
||||
}
|
||||
async fn check(
|
||||
&self,
|
||||
@@ -451,15 +408,26 @@ impl Compiled {
|
||||
Ok((parts.status, parts.headers))
|
||||
}
|
||||
}
|
||||
impl Identity {
|
||||
pub fn apply(&self, headers: &mut HeaderMap) {
|
||||
for name in self.headers.keys() {
|
||||
headers.remove(name);
|
||||
}
|
||||
for (name, value) in &self.headers {
|
||||
headers.append(name.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
include!("tests.rs");
|
||||
|
||||
impl crate::auth_provider::Provider for Compiled {
|
||||
fn id(&self) -> &'static str {
|
||||
"tinyauth"
|
||||
}
|
||||
fn strip_identity(&self, headers: &mut HeaderMap) {
|
||||
Compiled::strip_identity(self, headers);
|
||||
}
|
||||
fn authorize<'a>(
|
||||
&'a self,
|
||||
incoming: &'a HeaderMap,
|
||||
method: &'a Method,
|
||||
context: &'a forwarding::Context,
|
||||
shutdown: &'a tokio_util::sync::CancellationToken,
|
||||
_cookies: &'a crate::auth_provider::CookieSink,
|
||||
) -> crate::auth_provider::Authorization<'a> {
|
||||
Box::pin(Compiled::authorize(
|
||||
self, incoming, method, context, shutdown,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -208,7 +208,7 @@ mod tests {
|
||||
"Tinyauth timeout",
|
||||
),
|
||||
] {
|
||||
let response = gateway_failure(kind);
|
||||
let response = auth_provider::gateway_failure("Tinyauth", kind);
|
||||
assert_eq!(response.status(), status);
|
||||
assert_eq!(
|
||||
response.headers()["content-type"],
|
||||
@@ -218,7 +218,7 @@ mod tests {
|
||||
assert_eq!(response.headers()["x-content-type-options"], "nosniff");
|
||||
assert!(response
|
||||
.extensions()
|
||||
.get::<AuthenticationFailure>()
|
||||
.get::<auth_provider::AuthenticationResponse>()
|
||||
.is_some());
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let html = std::str::from_utf8(&body).unwrap();
|
||||
|
||||
+15
-6
@@ -54,7 +54,10 @@ pub struct MethodPolicy {
|
||||
}
|
||||
impl MethodPolicy {
|
||||
pub fn validate(&mut self) -> Result<()> {
|
||||
ensure!(self.methods.len() <= 32, "method policy allows at most 32 methods");
|
||||
ensure!(
|
||||
self.methods.len() <= 32,
|
||||
"method policy allows at most 32 methods"
|
||||
);
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for method in &mut self.methods {
|
||||
*method = method.trim().to_ascii_uppercase();
|
||||
@@ -62,7 +65,10 @@ impl MethodPolicy {
|
||||
!method.is_empty() && method.len() <= 32 && method.parse::<http::Method>().is_ok(),
|
||||
"invalid HTTP method in method policy"
|
||||
);
|
||||
ensure!(seen.insert(method.clone()), "duplicate HTTP method in method policy");
|
||||
ensure!(
|
||||
seen.insert(method.clone()),
|
||||
"duplicate HTTP method in method policy"
|
||||
);
|
||||
}
|
||||
ensure!(
|
||||
self.mode == MethodPolicyMode::Off || !self.methods.is_empty(),
|
||||
@@ -115,9 +121,7 @@ impl ContentTypePolicy {
|
||||
anyhow::bail!("invalid blocked Content-Type: expected type/subtype");
|
||||
};
|
||||
ensure!(
|
||||
mime_token(kind)
|
||||
&& (subtype == "*" || mime_token(subtype))
|
||||
&& value.len() <= 255,
|
||||
mime_token(kind) && (subtype == "*" || mime_token(subtype)) && value.len() <= 255,
|
||||
"invalid blocked Content-Type: use type/subtype or type/* without parameters"
|
||||
);
|
||||
ensure!(
|
||||
@@ -346,6 +350,10 @@ pub struct Vhost {
|
||||
#[serde(default)]
|
||||
pub tinyauth: crate::tinyauth::Settings,
|
||||
#[serde(default)]
|
||||
pub authelia: crate::authelia::Settings,
|
||||
#[serde(default)]
|
||||
pub authentik: crate::authentik::Settings,
|
||||
#[serde(default)]
|
||||
pub certificate_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub allow_certificate_mismatch: bool,
|
||||
@@ -379,7 +387,8 @@ pub struct Vhost {
|
||||
pub headers: HeaderRules,
|
||||
#[serde(default)]
|
||||
pub security_headers: SecurityHeaders,
|
||||
#[serde(default)] pub user_agent_blocking: crate::user_agent::Policy,
|
||||
#[serde(default)]
|
||||
pub user_agent_blocking: crate::user_agent::Policy,
|
||||
#[serde(default)]
|
||||
pub proxy_timeouts: ProxyTimeouts,
|
||||
#[serde(skip)]
|
||||
|
||||
@@ -242,12 +242,14 @@ impl Vhost {
|
||||
if let Some(server) = &self.server_header {
|
||||
server.validate()?;
|
||||
}
|
||||
self.tinyauth.validate()?;
|
||||
self.basic_auth.compile()?;
|
||||
ensure!(
|
||||
!(self.tinyauth.enabled && self.basic_auth.enabled),
|
||||
"choose either Tinyauth or Basic authentication"
|
||||
);
|
||||
crate::auth_provider::Selection {
|
||||
basic_enabled: self.basic_auth.enabled,
|
||||
tinyauth: &self.tinyauth,
|
||||
authelia: &self.authelia,
|
||||
authentik: &self.authentik,
|
||||
}
|
||||
.validate()?;
|
||||
self.pages.validate()?;
|
||||
self.transform
|
||||
.resolve(&crate::transform::Settings::default())?;
|
||||
|
||||
Reference in new issue
Block a user