This commit is contained in:
Mateusz Gruszczyński committed 2026-10-27 13:22:43 +01:00
1 parent e364392659
commit f27fbf6a30
28 files changed
+2000 -60

No files matched your search

+34 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.34.15",
"version": "0.34.16",
"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,37 @@
"id"
]
},
"AuthenticationChoice": {
"type": "object",
"description": "Optional explicit provider and integration method. Absent means infer the existing active Basic/Tinyauth/Authelia/authentik/OIDC configuration for backward compatibility. The selected method must match the one enabled legacy settings object; unknown, inconsistent or unsupported pairs are rejected.",
"additionalProperties": false,
"required": ["provider", "method"],
"properties": {
"provider": {"type": "string", "enum": ["basic", "tinyauth", "authelia", "authentik", "pocket_id", "custom"]},
"method": {"type": "string", "enum": ["basic", "forward_auth", "oidc"], "description": "Basic supports basic only; Tinyauth/Authelia/authentik support forward_auth or oidc; Pocket ID/custom support oidc only."}
}
},
"OidcConfig": {
"type": "object",
"description": "Opt-in OpenID Connect Authorization Code integration method for an identity provider selected by authentication.provider. Uses issuer discovery, S256 PKCE, nonce/state cookies, JWKS RS256/ES256 ID token verification and encrypted host-only application sessions. Existing standalone oidc.enabled configurations remain supported.",
"additionalProperties": false,
"properties": {
"enabled": {"type": "boolean", "default": false},
"issuer_url": {"type": "string", "format": "uri", "description": "Exact HTTPS OIDC issuer; /.well-known/openid-configuration is discovered at this URL"},
"backend_url": {"type": "string", "format": "uri", "description": "Optional internal HTTP(S) origin for server-side discovery/token/JWKS/UserInfo requests; public issuer and JWT validation remain unchanged"},
"redirect_url": {"type": "string", "format": "uri", "description": "HTTPS URL for an exact hostname in this Vhost and path /_rustproxy/oidc/callback"},
"client_id": {"type": "string", "maxLength": 256},
"client_secret": {"type": "string", "maxLength": 2048, "description": "Confidential OIDC client secret; stored in Vhost configuration"},
"session_secret": {"type": "string", "minLength": 32, "maxLength": 4096, "description": "High-entropy secret for encrypted session and temporary state cookies"},
"scopes": {"type": "string", "default": "openid profile email"},
"allow_private": {"type": "boolean", "default": false},
"address_allowlist": {"type": "array", "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},
"session_ttl_seconds": {"type": "integer", "minimum": 60, "maximum": 86400, "default": 3600}
}
},
"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.",
@@ -560,6 +591,8 @@
"$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."
},
"oidc": {"$ref": "#/components/schemas/OidcConfig"},
"authentication": {"$ref": "#/components/schemas/AuthenticationChoice", "description": "Optional provider/method selector. Set to null or omit for legacy Vhost data. It does not replace the existing provider-specific settings objects."},
"cache": {
"type": "object",
"properties": {
+80 -10
View File
@@ -10,7 +10,7 @@ function basicAuthEditor(parent, settings = {}) {
row.prepare = async () => { if (!password.value) { if (!row.userHash) throw new Error(t("basicHashRequired")); return; } const name = username.value.trim(), secret = password.value, epoch = draftEpoch; const result = await api("/api/basic-auth/hash", { method: "POST", body: JSON.stringify({ username: name, password: secret }) }); if (epoch !== draftEpoch || !row.isConnected || password.value !== secret || username.value.trim() !== name) throw new Error(t("vhUnsaved")); row.userHash = result.password_hash; password.value = ""; password.placeholder = t("basicKeepPassword"); state.textContent = t("basicHashSaved"); };
}
for (const user of settings.users || []) add(user); parent.append(button("basicAddUser", () => { add(); changed(); })); note(parent, "basicHelp");
return { async prepare() { for (const row of rows.children) await row.prepare(); }, read: () => ({ enabled: str("auth-mode") === "basic", realm: realm.value.trim(), require_tls: secure.checked, forward_credentials: forward.checked, users: [...rows.children].map(row => ({ username: str("basic-username", row), password_hash: row.userHash })) }) };
return { async prepare() { for (const row of rows.children) await row.prepare(); }, read: () => ({ enabled: selectedAuthentication().provider === "basic", realm: realm.value.trim(), require_tls: secure.checked, forward_credentials: forward.checked, users: [...rows.children].map(row => ({ username: str("basic-username", row), password_hash: row.userHash })) }) };
}
function hostTagEditor(parent, hosts = [], onUpdate = () => { }) {
@@ -33,10 +33,29 @@ function hostTagEditor(parent, hosts = [], onUpdate = () => { }) {
return () => { if (input.value.trim()) consume(input.value); if (!values.length) throw new Error(t("vhHostRequired")); return [...values]; };
}
const authMethodsByProvider = {
none: [], basic: ["basic"], tinyauth: ["forward_auth", "oidc"],
authelia: ["forward_auth", "oidc"], authentik: ["forward_auth", "oidc"],
pocket_id: ["oidc"], custom: ["oidc"]
};
function inferredAuthentication(value) {
if (value.authentication?.provider && value.authentication?.method) return value.authentication;
if (value.basic_auth?.enabled) return { provider: "basic", method: "basic" };
for (const provider of ["tinyauth", "authelia", "authentik"]) {
if (value[provider]?.enabled) return { provider, method: "forward_auth" };
}
if (value.oidc?.enabled) return { provider: "custom", method: "oidc" };
return { provider: "none", method: "none" };
}
function selectedAuthentication() {
const provider = str("auth-provider"), method = str("auth-method");
return provider === "none" ? { provider, method: "none" } : { provider, method };
}
function readForwardProvider(id) {
const value = key => str(`${id}-${key}`);
return {
enabled: str("auth-mode") === id,
enabled: selectedAuthentication().provider === id && selectedAuthentication().method === "forward_auth",
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`),
@@ -344,7 +363,9 @@ 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" : value.authelia?.enabled ? "authelia" : value.authentik?.enabled ? "authentik" : "none", "select", { values: [["none", "authNone"], ["tinyauth", "authTinyauth"], ["authelia", "authAuthelia"], ["authentik", "authAuthentik"], ["basic", "authBasic"]] });
const initialAuth = inferredAuthentication(value);
const authProvider = field(g, "auth-provider", "authProvider", initialAuth.provider, "select", { values: [["none", "authNone"], ["tinyauth", "authTinyauth"], ["authelia", "authAuthelia"], ["authentik", "authAuthentik"], ["pocket_id", "authPocketId"], ["custom", "authCustomOidc"], ["basic", "authBasic"]] });
const authMethod = field(g, "auth-method", "authMethod", initialAuth.method, "select", { values: [["forward_auth", "authForwardAuth"], ["oidc", "authOidc"], ["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);
@@ -397,21 +418,63 @@ function load(value = defaults(), migrationIds = [], show = true) {
forwardEditors[id] = { body, url, login };
}
const oidc = value.oidc || {};
const oidcBody = el("section", undefined, "vh-wide auth-subcard"); g.append(oidcBody);
oidcBody.append(text("h3", "authOidc"));
const oidcPrimary = el("div", undefined, "vh-grid"); oidcBody.append(oidcPrimary);
const oidcIssuer = field(oidcPrimary, "oidc-issuer", "oidcIssuer", oidc.issuer_url || "", "url", { maxLength: 2048 });
oidcIssuer.placeholder = "https://auth.example.com";
const oidcBackend = field(oidcPrimary, "oidc-backend", "oidcBackend", oidc.backend_url || "", "url", { maxLength: 2048 });
oidcBackend.placeholder = "http://172.17.0.20:9091";
const oidcRedirect = field(oidcPrimary, "oidc-redirect", "oidcRedirect", oidc.redirect_url || (value.frontend?.hosts || []).filter(host => !host.startsWith("*.")).map(host => `https://${host}/_rustproxy/oidc/callback`)[0] || "", "url", { maxLength: 2048 });
oidcRedirect.placeholder = "https://app.example.com/_rustproxy/oidc/callback";
const oidcClient = field(oidcPrimary, "oidc-client", "oidcClient", oidc.client_id || "", "text", { maxLength: 256 });
const oidcSecret = field(oidcPrimary, "oidc-secret", "oidcSecret", oidc.client_secret || "", "password", { maxLength: 2048 });
oidcSecret.autocomplete = "new-password";
const oidcSession = field(oidcPrimary, "oidc-session", "oidcSession", oidc.session_secret || "", "password", { maxLength: 4096 });
oidcSession.autocomplete = "new-password";
const oidcScopes = field(oidcPrimary, "oidc-scopes", "oidcScopes", oidc.scopes || "openid profile email", "text", { maxLength: 1024 });
const oidcOptions = el("div", undefined, "vh-wide auth-provider-options"); oidcPrimary.append(oidcOptions);
field(oidcOptions, "oidc-private", "vhPrivate", oidc.allow_private === true, "checkbox");
note(oidcBody, "oidcHelp");
const oidcAdvanced = el("details", undefined, "vh-wide auth-advanced"); oidcAdvanced.append(text("summary", "forwardAuthAdvanced"));
const oidcAdvancedGrid = el("div", undefined, "vh-grid"); oidcAdvanced.append(oidcAdvancedGrid); oidcBody.append(oidcAdvanced);
field(oidcAdvancedGrid, "oidc-connect", "taConnect", oidc.connect_timeout_seconds ?? 5, "number", { min: 1, max: 30, required: true });
field(oidcAdvancedGrid, "oidc-timeout", "taTimeout", oidc.timeout_seconds ?? 10, "number", { min: 1, max: 60, required: true });
field(oidcAdvancedGrid, "oidc-ttl", "oidcTtl", oidc.session_ttl_seconds ?? 3600, "number", { min: 60, max: 86400, required: true });
field(oidcAdvancedGrid, "oidc-allowlist", "vhAddressAllowlist", (oidc.address_allowlist || []).join("\n"), "textarea", { rows: 2, maxLength: 8192 });
field(oidcAdvancedGrid, "oidc-ca", "vhCa", oidc.tls_ca_pem || "", "textarea", { rows: 2, maxLength: 65536 });
basicBody.append(text("h3", "authBasic"));
const basicGrid = el("div", undefined, "vh-grid"); basicBody.append(basicGrid);
basicEditor = basicAuthEditor(basicGrid, value.basic_auth || {});
const syncAuth = () => {
const tinyauthSelected = authMode.value === "tinyauth";
const methods = authMethodsByProvider[authProvider.value] || [];
const previous = authMethod.value;
authMethod.replaceChildren(...methods.map(method => {
const label = method === "forward_auth" ? "authForwardAuth" : method === "basic" ? "authBasic" : "authOidc";
const option = text("option", label); option.value = method; return option;
}));
if (methods.includes(previous)) authMethod.value = previous;
authMethod.closest("label").hidden = authProvider.value === "none";
const { provider, method } = selectedAuthentication();
const forward = method === "forward_auth";
const tinyauthSelected = provider === "tinyauth" && forward;
taBody.hidden = !tinyauthSelected;
basicBody.hidden = authMode.value !== "basic";
basicBody.hidden = provider !== "basic";
authUrl.required = loginUrl.required = tinyauthSelected;
oidcBody.hidden = method !== "oidc";
for (const input of [oidcIssuer, oidcRedirect, oidcClient, oidcSecret, oidcSession, oidcScopes]) input.required = method === "oidc";
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;
const selected = provider === id && forward;
editor.body.hidden = !selected;
editor.url.required = selected;
if (editor.login) editor.login.required = selected;
}
};
authMode.addEventListener("change", syncAuth); syncAuth();
authProvider.addEventListener("change", syncAuth);
authMethod.addEventListener("change", syncAuth);
syncAuth();
g = group("vhResponsePolicy"); const server = value.server_header, diagnostics = value.diagnostic_headers;
const serverMode = field(g, "server-mode", "serverHeader", server?.mode || "inherit", "select", { values: [["inherit", "vhInherit"], ["preserve", "serverPreserve"], ["hide", "serverHide"], ["custom", "serverCustom"]] });
const serverValue = field(g, "server-value", "serverValue", server?.value || "RustProxy", "text", { maxLength: 256 });
@@ -554,8 +617,15 @@ 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: 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") },
authentication: selectedAuthentication().provider === "none" ? null : selectedAuthentication(),
tinyauth: { enabled: selectedAuthentication().provider === "tinyauth" && selectedAuthentication().method === "forward_auth", 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"),
oidc: { enabled: selectedAuthentication().method === "oidc", issuer_url: str("oidc-issuer"), backend_url: str("oidc-backend"),
redirect_url: str("oidc-redirect"), client_id: str("oidc-client"),
client_secret: str("oidc-secret"), session_secret: str("oidc-session"), scopes: str("oidc-scopes"),
allow_private: yes("oidc-private"), address_allowlist: lines(str("oidc-allowlist")),
tls_ca_pem: nullable("oidc-ca"), connect_timeout_seconds: num("oidc-connect"),
timeout_seconds: num("oidc-timeout"), session_ttl_seconds: num("oidc-ttl") },
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") },
+12 -4
View File
@@ -386,10 +386,18 @@ function render() {
const health = el("p", t("vhHealthEmpty"), "vh-health-summary"); health.dataset.healthFor = v.id; box.append(health);
}
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.authentication?.provider) {
const providers = { basic: "authBasic", tinyauth: "authTinyauth", authelia: "authAuthelia", authentik: "authAuthentik", pocket_id: "authPocketId", custom: "authCustomOidc" };
const provider = providers[v.authentication.provider];
const method = v.authentication.method;
if (provider) policy.append(el("small", method === "basic" ? t(provider) : `${t(provider)} · ${method === "oidc" ? "OIDC" : t("authForwardAuth")}`, "pill"));
} else {
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.oidc?.enabled) policy.append(el("small", "OIDC", "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"));
+115
View File
@@ -0,0 +1,115 @@
use anyhow::{ensure, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IdentityProvider {
Basic,
Tinyauth,
Authelia,
Authentik,
PocketId,
Custom,
}
impl IdentityProvider {
pub fn id(self) -> &'static str {
match self {
Self::Basic => "basic",
Self::Tinyauth => "tinyauth",
Self::Authelia => "authelia",
Self::Authentik => "authentik",
Self::PocketId => "pocket_id",
Self::Custom => "custom",
}
}
pub fn display_name(self) -> &'static str {
match self {
Self::Basic => "HTTP Basic Auth",
Self::Tinyauth => "Tinyauth",
Self::Authelia => "Authelia",
Self::Authentik => "authentik",
Self::PocketId => "Pocket ID",
Self::Custom => "OIDC",
}
}
pub fn supports(self, method: IntegrationMethod) -> bool {
match self {
Self::Basic => method == IntegrationMethod::Basic,
Self::Tinyauth | Self::Authelia | Self::Authentik => method != IntegrationMethod::Basic,
Self::PocketId | Self::Custom => method == IntegrationMethod::Oidc,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IntegrationMethod {
Basic,
ForwardAuth,
Oidc,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Choice {
pub provider: IdentityProvider,
pub method: IntegrationMethod,
}
impl Choice {
pub fn validate(self) -> Result<()> {
ensure!(
self.provider.supports(self.method),
"unsupported authentication provider and integration method combination"
);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn supported_methods_are_explicit_and_reject_invalid_pairs() {
for provider in [
IdentityProvider::Tinyauth,
IdentityProvider::Authelia,
IdentityProvider::Authentik,
] {
assert!(provider.supports(IntegrationMethod::ForwardAuth));
assert!(provider.supports(IntegrationMethod::Oidc));
}
for provider in [IdentityProvider::PocketId, IdentityProvider::Custom] {
assert!(!provider.supports(IntegrationMethod::ForwardAuth));
assert!(provider.supports(IntegrationMethod::Oidc));
}
assert!(IdentityProvider::Basic.supports(IntegrationMethod::Basic));
assert!(!IdentityProvider::Basic.supports(IntegrationMethod::Oidc));
assert!(!IdentityProvider::Authelia.supports(IntegrationMethod::Basic));
}
#[test]
fn selection_serializes_as_independent_provider_and_method() {
let selection = Choice {
provider: IdentityProvider::Authelia,
method: IntegrationMethod::Oidc,
};
let json = serde_json::to_value(selection).unwrap();
assert_eq!(
json,
serde_json::json!({"provider":"authelia","method":"oidc"})
);
assert_eq!(serde_json::from_value::<Choice>(json).unwrap(), selection);
assert!(serde_json::from_value::<Choice>(
serde_json::json!({"provider":"pocket_id","method":"forward_auth"})
)
.unwrap()
.validate()
.is_err());
assert!(serde_json::from_value::<Choice>(
serde_json::json!({"provider":"unknown","method":"oidc"})
)
.is_err());
}
}
+158 -2
View File
@@ -1,4 +1,4 @@
use crate::{authelia, authentik, config::ValidationPolicy, forwarding, tinyauth};
use crate::{authelia, authentik, config::ValidationPolicy, forwarding, oidc, tinyauth};
use anyhow::{ensure, Result};
use axum::{body::Body, response::Response};
use http::{header, HeaderMap, HeaderValue, Method, Request};
@@ -14,7 +14,9 @@ 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 choice;
mod errors;
pub use choice::{Choice, IdentityProvider, IntegrationMethod};
pub use errors::{deny, gateway_failure, GatewayFailure};
#[derive(Debug, Default)]
@@ -34,6 +36,9 @@ impl Identity {
pub trait Provider: Send + Sync + std::fmt::Debug {
fn id(&self) -> &'static str;
fn method(&self) -> IntegrationMethod {
IntegrationMethod::ForwardAuth
}
fn strip_identity(&self, headers: &mut HeaderMap);
fn authorize<'a>(
&'a self,
@@ -66,6 +71,9 @@ impl Compiled {
pub fn id(&self) -> &'static str {
self.0.id()
}
pub fn method(&self) -> IntegrationMethod {
self.0.method()
}
pub fn strip_identity(&self, headers: &mut HeaderMap) {
self.0.strip_identity(headers);
}
@@ -95,26 +103,49 @@ impl Compiled {
}
pub struct Selection<'a> {
pub choice: Option<Choice>,
pub basic_enabled: bool,
pub tinyauth: &'a tinyauth::Settings,
pub authelia: &'a authelia::Settings,
pub authentik: &'a authentik::Settings,
pub oidc: &'a oidc::Settings,
}
impl Selection<'_> {
pub fn validate(&self) -> Result<()> {
self.tinyauth.validate()?;
self.authelia.validate()?;
self.authentik.validate()?;
self.oidc.validate()?;
let active = [
self.basic_enabled,
self.tinyauth.enabled,
self.authelia.enabled,
self.authentik.enabled,
self.oidc.enabled,
];
ensure!(
active.iter().filter(|enabled| **enabled).count() <= 1,
"choose only one authentication provider (Basic, Tinyauth, Authelia or authentik)"
"choose only one authentication method per Vhost"
);
if let Some(choice) = self.choice {
choice.validate()?;
let matches_configuration = match choice.method {
IntegrationMethod::Basic => {
self.basic_enabled && choice.provider == IdentityProvider::Basic
}
IntegrationMethod::ForwardAuth => match choice.provider {
IdentityProvider::Tinyauth => self.tinyauth.enabled,
IdentityProvider::Authelia => self.authelia.enabled,
IdentityProvider::Authentik => self.authentik.enabled,
_ => false,
},
IntegrationMethod::Oidc => self.oidc.enabled,
};
ensure!(
matches_configuration,
"authentication selection does not match the enabled settings"
);
}
Ok(())
}
pub fn compile(
@@ -132,6 +163,15 @@ impl Selection<'_> {
if self.authentik.enabled {
return Ok(self.authentik.compile(policy, bindings)?.map(Compiled::new));
}
if self.oidc.enabled {
let (id, label) = self.choice.map_or(("oidc", "OIDC"), |choice| {
(choice.provider.id(), choice.provider.display_name())
});
return Ok(self
.oidc
.compile(policy, bindings)?
.map(|provider| Compiled::new(provider.with_provider(id, label))));
}
Ok(None)
}
}
@@ -184,10 +224,12 @@ mod tests {
}))
.unwrap();
let selection = Selection {
choice: host.authentication,
basic_enabled: host.basic_auth.enabled,
tinyauth: &host.tinyauth,
authelia: &host.authelia,
authentik: &host.authentik,
oidc: &host.oidc,
};
assert!(selection.validate().is_ok());
}
@@ -196,16 +238,129 @@ mod tests {
let mut tinyauth = tinyauth::Settings::default();
let mut authelia = authelia::Settings::default();
let authentik = authentik::Settings::default();
let oidc = oidc::Settings::default();
tinyauth.enabled = true;
authelia.enabled = true;
let selection = Selection {
choice: None,
basic_enabled: false,
tinyauth: &tinyauth,
authelia: &authelia,
authentik: &authentik,
oidc: &oidc,
};
assert!(selection.validate().is_err());
}
#[test]
fn oidc_is_exclusive_and_missing_legacy_values_remain_disabled() {
let mut tinyauth = tinyauth::Settings::default();
tinyauth.enabled = true;
tinyauth.url = "http://127.0.0.1:3000".into();
tinyauth.login_url = "https://auth.example.net".into();
let oidc = oidc::Settings {
enabled: true,
issuer_url: "https://auth.test.com".into(),
backend_url: "http://127.0.0.1:9091".into(),
client_id: "test-client".into(),
client_secret: "test-secret".into(),
redirect_url: "https://app.example.com/_rustproxy/oidc/callback".into(),
session_secret: "long-session-secret-at-least-32-characters".into(),
..oidc::Settings::default()
};
assert!(Selection {
choice: None,
basic_enabled: false,
tinyauth: &tinyauth,
authelia: &authelia::Settings::default(),
authentik: &authentik::Settings::default(),
oidc: &oidc,
}
.validate()
.is_err());
}
#[test]
fn oidc_identity_provider_is_independent_of_integration_method() {
let mut oidc = oidc::Settings::default();
oidc.enabled = true;
oidc.issuer_url = "https://auth.test.com".into();
oidc.client_id = "rustproxy-test".into();
oidc.client_secret = "test-client-secret".into();
oidc.session_secret = "a-long-random-value-for-testing-oidc-sessions".into();
oidc.redirect_url = "https://app.example.com/_rustproxy/oidc/callback".into();
for provider in [
IdentityProvider::Tinyauth,
IdentityProvider::Authelia,
IdentityProvider::Authentik,
IdentityProvider::PocketId,
IdentityProvider::Custom,
] {
assert!(Selection {
choice: Some(Choice {
provider,
method: IntegrationMethod::Oidc
}),
basic_enabled: false,
tinyauth: &tinyauth::Settings::default(),
authelia: &authelia::Settings::default(),
authentik: &authentik::Settings::default(),
oidc: &oidc,
}
.validate()
.is_ok());
}
assert!(Selection {
choice: Some(Choice {
provider: IdentityProvider::Authelia,
method: IntegrationMethod::ForwardAuth
}),
basic_enabled: false,
tinyauth: &tinyauth::Settings::default(),
authelia: &authelia::Settings::default(),
authentik: &authentik::Settings::default(),
oidc: &oidc,
}
.validate()
.is_err());
}
#[test]
fn the_new_selection_cannot_silently_disable_or_switch_authentication() {
let mut tinyauth = tinyauth::Settings::default();
tinyauth.enabled = true;
tinyauth.url = "http://10.0.0.2:3000".into();
tinyauth.login_url = "https://auth.example.com".into();
let authelia = authelia::Settings::default();
let authentik = authentik::Settings::default();
let oidc = oidc::Settings::default();
let selection = |choice| Selection {
choice,
basic_enabled: false,
tinyauth: &tinyauth,
authelia: &authelia,
authentik: &authentik,
oidc: &oidc,
};
assert!(selection(None).validate().is_ok());
assert!(selection(Some(Choice {
provider: IdentityProvider::Tinyauth,
method: IntegrationMethod::ForwardAuth
}))
.validate()
.is_ok());
assert!(selection(Some(Choice {
provider: IdentityProvider::Authelia,
method: IntegrationMethod::ForwardAuth
}))
.validate()
.is_err());
assert!(selection(Some(Choice {
provider: IdentityProvider::Tinyauth,
method: IntegrationMethod::Oidc
}))
.validate()
.is_err());
}
#[derive(Debug)]
struct FutureProvider;
impl Provider for FutureProvider {
@@ -228,6 +383,7 @@ mod tests {
async fn future_providers_can_use_runtime_interface_without_proxy_branches() {
let provider = Compiled::new(FutureProvider);
assert_eq!(provider.id(), "future");
assert_eq!(provider.method(), IntegrationMethod::ForwardAuth);
assert!(!provider.reserved_path("/"));
let headers = HeaderMap::new();
let response = provider
+2
View File
@@ -217,10 +217,12 @@ fn compile_routing(
.clone()
.unwrap_or_else(|| config.diagnostic_headers.clone()),
auth_provider: crate::auth_provider::Selection {
choice: host.authentication,
basic_enabled: host.basic_auth.enabled,
tinyauth: &host.tinyauth,
authelia: &host.authelia,
authentik: &host.authentik,
oidc: &host.oidc,
}
.compile(&desired_policy, &guarded_bindings)?,
id: host.id.clone(),
+41
View File
@@ -35,9 +35,12 @@ mod tests {
let mut legacy = encoded.as_object().unwrap().clone();
legacy.remove("authelia");
legacy.remove("authentik");
legacy.remove("oidc");
legacy.remove("authentication");
let restored: crate::vhost::Vhost = serde_json::from_value(legacy.into()).unwrap();
assert!(!restored.authelia.enabled);
assert!(!restored.authentik.enabled);
assert!(!restored.oidc.enabled);
}
#[test]
@@ -46,6 +49,7 @@ mod tests {
("tinyauth", "authelia"),
("authelia", "authentik"),
("authentik", "basic"),
("oidc", "tinyauth"),
] {
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
let host = &mut config.vhosts[0];
@@ -54,6 +58,7 @@ mod tests {
"tinyauth" => host.tinyauth.enabled = true,
"authelia" => host.authelia.enabled = true,
"authentik" => host.authentik.enabled = true,
"oidc" => host.oidc.enabled = true,
"basic" => host.basic_auth.enabled = true,
_ => unreachable!(),
}
@@ -65,6 +70,42 @@ mod tests {
}
}
#[test]
fn explicit_authentication_selection_roundtrips_and_legacy_remains_supported() {
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
let mut host = config.vhosts.remove(0);
assert!(host.authentication.is_none());
host.authentication = Some(crate::auth_provider::Choice {
provider: crate::auth_provider::IdentityProvider::Authelia,
method: crate::auth_provider::IntegrationMethod::Oidc,
});
host.oidc.enabled = true;
host.oidc.issuer_url = "https://auth.test.com".into();
host.oidc.client_id = "app".into();
host.oidc.client_secret = "client-secret".into();
host.oidc.session_secret = "a-unique-testing-session-secret-32-bytes".into();
host.oidc.redirect_url = "https://localhost/_rustproxy/oidc/callback".into();
assert!(host.validate().is_ok());
let encoded = serde_json::to_value(&host).unwrap();
assert_eq!(
encoded["authentication"],
serde_json::json!({"provider":"authelia","method":"oidc"})
);
let restored: crate::vhost::Vhost = serde_json::from_value(encoded.clone()).unwrap();
assert_eq!(restored.authentication, host.authentication);
assert!(restored.validate().is_ok());
let mut legacy = encoded.as_object().unwrap().clone();
legacy.remove("authentication");
let old: crate::vhost::Vhost = serde_json::from_value(legacy.into()).unwrap();
assert!(old.authentication.is_none());
assert!(old.validate().is_ok());
host.authentication = Some(crate::auth_provider::Choice {
provider: crate::auth_provider::IdentityProvider::PocketId,
method: crate::auth_provider::IntegrationMethod::ForwardAuth,
});
assert!(host.validate().is_err());
}
#[test]
fn missing_http2_settings_use_release_defaults() {
let config: Config = serde_json::from_value(serde_json::json!({})).unwrap();
+1
View File
@@ -34,6 +34,7 @@ pub mod transform;
pub mod auth_provider;
pub mod authelia;
pub mod authentik;
pub mod oidc;
pub mod balance;
pub mod forward_auth;
pub mod forwarding;
+880
View File
@@ -0,0 +1,880 @@
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 base64::{
engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
Engine as _,
};
use http::{header, HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
use http_body_util::{BodyExt, Limited};
use ring::{
aead, digest, hmac,
rand::{SecureRandom, SystemRandom},
};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use serde_json::Value;
use std::{
fmt,
net::SocketAddr,
sync::Arc,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use tokio::sync::Mutex;
use tokio_util::sync::CancellationToken;
use url::{form_urlencoded, Url};
mod token;
pub const CALLBACK: &str = "/_rustproxy/oidc/callback";
pub const LOGOUT: &str = "/_rustproxy/oidc/logout";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Settings {
pub enabled: bool,
pub issuer_url: String,
pub backend_url: String,
pub client_id: String,
pub client_secret: String,
pub scopes: String,
pub session_secret: String,
pub redirect_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 session_ttl_seconds: u64,
}
impl Default for Settings {
fn default() -> Self {
Self {
enabled: false,
issuer_url: String::new(),
backend_url: String::new(),
client_id: String::new(),
client_secret: String::new(),
scopes: "openid profile email".into(),
session_secret: String::new(),
redirect_url: String::new(),
allow_private: false,
address_allowlist: Vec::new(),
tls_ca_pem: None,
connect_timeout_seconds: 5,
timeout_seconds: 10,
session_ttl_seconds: 3600,
}
}
}
fn https_url(raw: &str) -> Result<Url> {
ensure!(
!raw.is_empty() && raw.len() <= 2048,
"OIDC URL must be 1..2048 bytes"
);
let url = Url::parse(raw)?;
ensure!(
url.scheme() == "https" && url.host_str().is_some(),
"OIDC requires HTTPS URLs"
);
ensure!(
url.username().is_empty()
&& url.password().is_none()
&& url.query().is_none()
&& url.fragment().is_none(),
"OIDC URL cannot contain credentials, queries or fragments"
);
Ok(url)
}
impl Settings {
pub fn validate(&self) -> Result<()> {
ensure!(
(1..=30).contains(&self.connect_timeout_seconds)
&& (1..=60).contains(&self.timeout_seconds)
&& self.connect_timeout_seconds <= self.timeout_seconds,
"OIDC timeouts must be 1..30s connect <= 1..60s total"
);
ensure!(
(60..=86400).contains(&self.session_ttl_seconds),
"OIDC session lifetime must be 60..86400 seconds"
);
if self.enabled {
let issuer = https_url(&self.issuer_url)?;
ensure!(!issuer.path().contains(".."), "OIDC issuer path is invalid");
if !self.backend_url.is_empty() {
let internal =
crate::forward_auth::http_url(&self.backend_url, "OIDC internal backend")?;
ensure!(
internal.path() == "/" && internal.query().is_none(),
"OIDC internal backend URL must be an origin without a path or query"
);
}
ensure!(
!self.client_id.is_empty()
&& self.client_id.len() <= 256
&& self
.client_id
.bytes()
.all(|c| c.is_ascii_alphanumeric() || b"-._~".contains(&c)),
"OIDC client ID must contain 1..256 URL-safe characters"
);
ensure!(
!self.client_secret.is_empty() && self.client_secret.len() <= 2048,
"OIDC client secret required"
);
ensure!(
self.session_secret.len() >= 32 && self.session_secret.len() <= 4096,
"OIDC session secret must be at least 32 bytes"
);
ensure!(
self.scopes.len() <= 1024
&& self
.scopes
.bytes()
.all(|c| c.is_ascii_alphanumeric() || b" -_:.".contains(&c)),
"OIDC scopes cannot contain control characters"
);
let scopes: Vec<_> = self.scopes.split_whitespace().collect();
ensure!(
scopes.len() <= 16
&& scopes.contains(&"openid")
&& scopes.iter().all(|item| item.len() <= 64
&& item
.bytes()
.all(|c| c.is_ascii_alphanumeric() || b"-_:.".contains(&c))),
"OIDC scopes must contain openid and valid space-separated names"
);
let redirect = https_url(&self.redirect_url)?;
ensure!(
redirect.path() == CALLBACK,
"OIDC redirect URL must end with {CALLBACK}"
);
}
Ok(())
}
pub fn compile(
&self,
policy: &ValidationPolicy,
bindings: &[SocketAddr],
) -> Result<Option<Compiled>> {
self.validate()?;
if !self.enabled {
return Ok(None);
}
let issuer = https_url(&self.issuer_url)?;
let redirect = https_url(&self.redirect_url)?;
let upstream_origin = if self.backend_url.is_empty() {
issuer.origin().ascii_serialization()
} else {
crate::forward_auth::http_url(&self.backend_url, "OIDC internal backend")?
.origin()
.ascii_serialization()
};
let public_host: Uri = issuer.as_str().parse()?;
let host_header = HeaderValue::from_str(
public_host
.authority()
.context("OIDC issuer authority missing")?
.as_str(),
)?;
let definition = Backend {
id: "oidc".into(),
url: upstream_origin.clone(),
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),
)?;
let fingerprint = digest::digest(
&digest::SHA256,
format!("{}:{}", self.client_id, self.redirect_url).as_bytes(),
);
let suffix = fingerprint.as_ref()[..8]
.iter()
.map(|c| format!("{c:02x}"))
.collect::<Vec<_>>()
.join("");
Ok(Some(Compiled {
provider_id: "oidc",
provider_label: "OIDC",
issuer: self.issuer_url.clone(),
client_id: self.client_id.clone(),
scopes: self.scopes.clone(),
client_secret: self.client_secret.clone(),
session_secret: scoped_secret(
&self.session_secret,
&self.issuer_url,
&self.client_id,
&self.redirect_url,
),
redirect,
backend: Arc::new(backend),
cache: Mutex::new(None),
upstream_origin: Url::parse(&upstream_origin)?,
host_header,
internal_override: !self.backend_url.is_empty(),
timeout: Duration::from_secs(self.timeout_seconds),
session_ttl: self.session_ttl_seconds,
session_cookie: format!("__Host-rpoidc-{suffix}"),
state_cookie: format!("__Host-rpoidc-state-{suffix}"),
}))
}
}
#[derive(Clone, Debug)]
struct Discovery {
authorization: Url,
token: Url,
userinfo: Option<Url>,
jwks: Value,
}
#[derive(Debug)]
struct Cached {
until: Instant,
discovery: Discovery,
}
pub struct Compiled {
provider_id: &'static str,
provider_label: &'static str,
issuer: String,
client_id: String,
client_secret: String,
scopes: String,
session_secret: String,
redirect: Url,
backend: Arc<CompiledBackend>,
upstream_origin: Url,
host_header: HeaderValue,
internal_override: bool,
cache: Mutex<Option<Cached>>,
timeout: Duration,
session_ttl: u64,
session_cookie: String,
state_cookie: String,
}
impl Compiled {
pub fn with_provider(mut self, provider: &'static str, label: &'static str) -> Self {
self.provider_id = provider;
self.provider_label = label;
self
}
}
impl fmt::Debug for Compiled {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OidcProvider")
.field("issuer", &self.issuer)
.field("redirect", &self.redirect)
.finish_non_exhaustive()
}
}
#[derive(Serialize, Deserialize)]
struct Pending {
state: String,
nonce: String,
verifier: String,
return_to: String,
issued: u64,
}
#[derive(Serialize, Deserialize)]
struct Session {
subject: String,
username: String,
email: Option<String>,
name: Option<String>,
groups: Vec<String>,
expires: u64,
}
fn now() -> Result<u64> {
Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs())
}
fn random_b64(bytes: usize) -> Result<String> {
ensure!(bytes <= 128, "random input too long");
let mut random = vec![0u8; bytes];
SystemRandom::new()
.fill(&mut random)
.map_err(|_| anyhow::anyhow!("random source unavailable"))?;
Ok(URL_SAFE_NO_PAD.encode(random))
}
fn scoped_secret(secret: &str, issuer: &str, client_id: &str, redirect: &str) -> String {
let key = hmac::Key::new(hmac::HMAC_SHA256, secret.as_bytes());
let context = format!("rustproxy-oidc-v1\0{issuer}\0{client_id}\0{redirect}");
URL_SAFE_NO_PAD.encode(hmac::sign(&key, context.as_bytes()).as_ref())
}
fn crypt_key(secret: &str) -> Result<aead::LessSafeKey> {
let hash = digest::digest(&digest::SHA256, secret.as_bytes());
Ok(aead::LessSafeKey::new(
aead::UnboundKey::new(&aead::AES_256_GCM, hash.as_ref())
.map_err(|_| anyhow::anyhow!("OIDC cipher initialization failed"))?,
))
}
fn seal<T: Serialize>(secret: &str, value: &T) -> Result<String> {
let mut nonce_bytes = [0u8; 12];
SystemRandom::new()
.fill(&mut nonce_bytes)
.map_err(|_| anyhow::anyhow!("random source unavailable"))?;
let mut bytes = serde_json::to_vec(value)?;
ensure!(bytes.len() <= 3072, "OIDC cookie contents too large");
crypt_key(secret)?
.seal_in_place_append_tag(
aead::Nonce::assume_unique_for_key(nonce_bytes),
aead::Aad::from("rustproxy-oidc-v1"),
&mut bytes,
)
.map_err(|_| anyhow::anyhow!("OIDC cookie encryption failure"))?;
let mut data = nonce_bytes.to_vec();
data.extend_from_slice(&bytes);
let encoded = URL_SAFE_NO_PAD.encode(data);
ensure!(encoded.len() <= 4096, "OIDC cookie too large");
Ok(encoded)
}
fn open<T: DeserializeOwned>(secret: &str, encoded: &str) -> Result<T> {
ensure!(encoded.len() <= 4096, "OIDC cookie too large");
let mut bytes = URL_SAFE_NO_PAD.decode(encoded)?;
ensure!(bytes.len() > 28, "invalid OIDC cookie");
let mut nonce = [0u8; 12];
nonce.copy_from_slice(&bytes[..12]);
let plaintext = crypt_key(secret)?
.open_in_place(
aead::Nonce::assume_unique_for_key(nonce),
aead::Aad::from("rustproxy-oidc-v1"),
&mut bytes[12..],
)
.map_err(|_| anyhow::anyhow!("invalid OIDC cookie authentication"))?;
Ok(serde_json::from_slice(plaintext)?)
}
fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
let mut selected = None;
for line in headers.get_all(header::COOKIE) {
for entry in line.to_str().ok()?.split(';') {
if let Some((key, value)) = entry.trim().split_once('=') {
if key == name {
if selected.is_some() {
return None;
}
selected = Some(value);
}
}
}
}
selected
}
fn cookie(name: &str, value: &str, age: u64) -> Result<HeaderValue> {
Ok(format!("{name}={value}; Path=/; Max-Age={age}; HttpOnly; Secure; SameSite=Lax").parse()?)
}
fn clear_cookie(name: &str) -> HeaderValue {
format!("{name}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax")
.parse()
.expect("static valid cookie name")
}
fn redirect(location: &str, status: StatusCode) -> Response {
let mut response = auth_provider::deny(status);
match location.parse() {
Ok(value) => {
response.headers_mut().insert(header::LOCATION, value);
}
Err(_) => {
return auth_provider::deny(StatusCode::BAD_GATEWAY);
}
}
response
}
fn sanitize_header(headers: &mut HeaderMap, name: &'static str, value: &str) {
if value.len() <= 2048 {
if let Ok(value) = value.parse() {
headers.insert(name, value);
}
}
}
fn identity(session: &Session, issuer: &str) -> Identity {
let mut headers = HeaderMap::new();
sanitize_header(&mut headers, "remote-sub", &session.subject);
sanitize_header(&mut headers, "remote-issuer", issuer);
sanitize_header(&mut headers, "remote-user", &session.username);
if let Some(email) = &session.email {
sanitize_header(&mut headers, "remote-email", email);
}
if let Some(name) = &session.name {
sanitize_header(&mut headers, "remote-name", name);
}
if !session.groups.is_empty() {
sanitize_header(&mut headers, "remote-groups", &session.groups.join(","));
}
Identity { headers }
}
impl Compiled {
fn url_for_endpoint(&self, raw: &str) -> Result<Url> {
let url = https_url(raw)?;
ensure!(
url.origin() == https_url(&self.issuer)?.origin(),
"OIDC endpoints must use the configured issuer origin"
);
Ok(url)
}
fn wire_uri(&self, public: &Url) -> Result<Uri> {
ensure!(
public.origin() == https_url(&self.issuer)?.origin(),
"OIDC request not on issuer origin"
);
let mut wire = self.upstream_origin.clone();
wire.set_path(public.path());
wire.set_query(public.query());
Ok(wire.as_str().parse()?)
}
fn wire_headers(&self, headers: &mut HeaderMap) {
headers.insert(header::HOST, self.host_header.clone());
if self.internal_override {
headers.insert("x-forwarded-proto", HeaderValue::from_static("https"));
}
}
async fn get_json(&self, url: &Url) -> Result<Value> {
let mut request = Request::builder()
.method(Method::GET)
.uri(self.wire_uri(url)?)
.body(Body::empty())?;
self.wire_headers(request.headers_mut());
request
.headers_mut()
.insert(header::ACCEPT, HeaderValue::from_static("application/json"));
let response = self.backend.client.request(request).await?;
ensure!(
response.status() == StatusCode::OK,
"OIDC endpoint returned an error"
);
let body = Limited::new(response.into_body(), 65536)
.collect()
.await
.map_err(|error| anyhow::anyhow!("invalid OIDC endpoint response: {error}"))?
.to_bytes();
Ok(serde_json::from_slice(&body)?)
}
async fn discovery(&self) -> Result<Discovery> {
let mut guard = self.cache.lock().await;
if let Some(saved) = guard.as_ref() {
if saved.until > Instant::now() {
return Ok(saved.discovery.clone());
}
}
let discovery_url = self.url_for_endpoint(&format!(
"{}/.well-known/openid-configuration",
self.issuer.trim_end_matches('/')
))?;
let metadata = self.get_json(&discovery_url).await?;
ensure!(
metadata.get("issuer").and_then(Value::as_str) == Some(self.issuer.as_str()),
"OIDC discovery issuer mismatch"
);
let authorization = self.url_for_endpoint(
metadata
.get("authorization_endpoint")
.and_then(Value::as_str)
.context("missing OIDC authorization endpoint")?,
)?;
let token = self.url_for_endpoint(
metadata
.get("token_endpoint")
.and_then(Value::as_str)
.context("missing OIDC token endpoint")?,
)?;
let userinfo = metadata
.get("userinfo_endpoint")
.and_then(Value::as_str)
.map(|url| self.url_for_endpoint(url))
.transpose()?;
let jwks_url = self.url_for_endpoint(
metadata
.get("jwks_uri")
.and_then(Value::as_str)
.context("missing OIDC JWKS URI")?,
)?;
let jwks = self.get_json(&jwks_url).await?;
ensure!(
jwks.get("keys").and_then(Value::as_array).is_some(),
"invalid OIDC signing keys"
);
let discovery = Discovery {
authorization,
token,
userinfo,
jwks,
};
*guard = Some(Cached {
until: Instant::now() + Duration::from_secs(300),
discovery: discovery.clone(),
});
Ok(discovery)
}
fn is_redirect_host(&self, context: &forwarding::Context) -> bool {
context.scheme == "https"
&& Url::parse(&context.original_url)
.is_ok_and(|url| url.origin() == self.redirect.origin())
}
async fn initiate(
&self,
context: &forwarding::Context,
cookies: &CookieSink,
) -> Result<Response> {
ensure!(
self.is_redirect_host(context),
"OIDC request origin does not match callback"
);
let destination = Url::parse(&context.original_url)?;
let mut return_to = destination.path().to_owned();
if let Some(query) = destination.query() {
return_to.push('?');
return_to.push_str(query);
}
ensure!(
return_to.len() <= 1800 && return_to.starts_with('/') && !return_to.starts_with("//"),
"invalid OIDC return URL"
);
let transaction = Pending {
state: random_b64(32)?,
nonce: random_b64(32)?,
verifier: random_b64(32)?,
return_to,
issued: now()?,
};
let mut authorization = self.discovery().await?.authorization;
let challenge = digest::digest(&digest::SHA256, transaction.verifier.as_bytes());
authorization
.query_pairs_mut()
.append_pair("response_type", "code")
.append_pair("client_id", &self.client_id)
.append_pair("redirect_uri", self.redirect.as_str())
.append_pair("scope", &self.scopes)
.append_pair("state", &transaction.state)
.append_pair("nonce", &transaction.nonce)
.append_pair("code_challenge_method", "S256")
.append_pair(
"code_challenge",
&URL_SAFE_NO_PAD.encode(challenge.as_ref()),
);
let mut response = redirect(authorization.as_str(), StatusCode::FOUND);
let mut set_cookie = HeaderMap::new();
set_cookie.append(
header::SET_COOKIE,
cookie(
&self.state_cookie,
&seal(&self.session_secret, &transaction)?,
300,
)?,
);
ensure!(
cookies.collect(&set_cookie),
"OIDC transaction cookie too large"
);
response
.headers_mut()
.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
Ok(response)
}
async fn exchange(&self, code: &str, pending: &Pending) -> Result<token::Claims> {
let discovery = self.discovery().await?;
let body = form_urlencoded::Serializer::new(String::new())
.append_pair("grant_type", "authorization_code")
.append_pair("code", code)
.append_pair("redirect_uri", self.redirect.as_str())
.append_pair("code_verifier", &pending.verifier)
.finish();
let id_encoded: String =
form_urlencoded::byte_serialize(self.client_id.as_bytes()).collect();
let secret_encoded: String =
form_urlencoded::byte_serialize(self.client_secret.as_bytes()).collect();
let authorization = STANDARD.encode(format!("{id_encoded}:{secret_encoded}"));
let mut request = Request::builder()
.method(Method::POST)
.uri(self.wire_uri(&discovery.token)?)
.body(Body::from(body))?;
self.wire_headers(request.headers_mut());
request.headers_mut().insert(
header::AUTHORIZATION,
format!("Basic {authorization}").parse()?,
);
request
.headers_mut()
.insert(header::ACCEPT, HeaderValue::from_static("application/json"));
request.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/x-www-form-urlencoded"),
);
let response = self.backend.client.request(request).await?;
ensure!(
response.status() == StatusCode::OK,
"OIDC token endpoint returned an error"
);
let body = Limited::new(response.into_body(), 65536)
.collect()
.await
.map_err(|error| anyhow::anyhow!("invalid OIDC token response: {error}"))?
.to_bytes();
let value: Value = serde_json::from_slice(&body)?;
let id_token = value
.get("id_token")
.and_then(Value::as_str)
.context("missing OIDC ID token")?;
let mut claims = token::verify_id_token(
id_token,
&discovery.jwks,
&self.issuer,
&self.client_id,
&pending.nonce,
)?;
if let (Some(userinfo_url), Some(access_token)) = (
discovery.userinfo.as_ref(),
value.get("access_token").and_then(Value::as_str),
) {
ensure!(
access_token.len() <= 8192 && !access_token.is_empty(),
"invalid OIDC access token"
);
let mut request = Request::builder()
.method(Method::GET)
.uri(self.wire_uri(userinfo_url)?)
.body(Body::empty())?;
self.wire_headers(request.headers_mut());
request.headers_mut().insert(
header::AUTHORIZATION,
format!("Bearer {access_token}").parse()?,
);
request
.headers_mut()
.insert(header::ACCEPT, HeaderValue::from_static("application/json"));
let response = self.backend.client.request(request).await?;
ensure!(
response.status() == StatusCode::OK,
"OIDC UserInfo endpoint returned an error"
);
let data = Limited::new(response.into_body(), 65536)
.collect()
.await
.map_err(|error| anyhow::anyhow!("invalid OIDC UserInfo response: {error}"))?
.to_bytes();
token::merge_userinfo(&mut claims, &serde_json::from_slice::<Value>(&data)?)?;
}
Ok(claims)
}
async fn callback(
&self,
request: Request<Body>,
context: &forwarding::Context,
shutdown: &CancellationToken,
) -> Response {
if request.method() != Method::GET || !self.is_redirect_host(context) {
return auth_provider::deny(StatusCode::BAD_REQUEST);
}
let mut valid_code = None;
let mut valid_state = None;
let mut error = false;
if let Some(query) = request.uri().query() {
if query.len() > 8192 {
return auth_provider::deny(StatusCode::BAD_REQUEST);
}
for (key, value) in form_urlencoded::parse(query.as_bytes()) {
match key.as_ref() {
"code" if valid_code.is_none() && value.len() <= 4096 => {
valid_code = Some(value.into_owned())
}
"state" if valid_state.is_none() && value.len() <= 256 => {
valid_state = Some(value.into_owned())
}
"error" => error = true,
"code" | "state" => error = true,
_ => {}
}
}
}
let Some(encoded) = cookie_value(request.headers(), &self.state_cookie) else {
return auth_provider::deny(StatusCode::UNAUTHORIZED);
};
let pending: Pending = match open(&self.session_secret, encoded) {
Ok(value) => value,
Err(_) => return auth_provider::deny(StatusCode::UNAUTHORIZED),
};
let current = match now() {
Ok(now) => now,
Err(_) => return auth_provider::deny(StatusCode::BAD_GATEWAY),
};
if error
|| current < pending.issued
|| current.saturating_sub(pending.issued) > 300
|| valid_state.as_deref() != Some(pending.state.as_str())
|| valid_code.as_deref().is_none_or(str::is_empty)
{
return auth_provider::deny(StatusCode::UNAUTHORIZED);
}
let code = valid_code.expect("validated authorization code");
let result = tokio::select! {
_ = shutdown.cancelled() => return auth_provider::deny(StatusCode::SERVICE_UNAVAILABLE),
result = tokio::time::timeout(self.timeout, self.exchange(&code, &pending)) => result,
};
let claims = match result {
Ok(Ok(claims)) => claims,
Ok(Err(error)) => {
tracing::warn!(provider = self.provider_id, %error, "OIDC callback verification failed");
return auth_provider::gateway_failure(
self.provider_label,
GatewayFailure::Verification,
);
}
Err(_) => {
return auth_provider::gateway_failure(self.provider_label, GatewayFailure::Timeout)
}
};
let session = Session {
subject: claims.subject,
username: claims.username,
email: claims.email,
name: claims.name,
groups: claims.groups,
expires: current.saturating_add(self.session_ttl),
};
let value = match seal(&self.session_secret, &session) {
Ok(value) => value,
Err(_) => {
return auth_provider::gateway_failure(
self.provider_label,
GatewayFailure::Verification,
)
}
};
let mut response = redirect(&pending.return_to, StatusCode::SEE_OTHER);
response
.headers_mut()
.append(header::SET_COOKIE, clear_cookie(&self.state_cookie));
response
.headers_mut()
.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
if let Ok(cookie) = cookie(&self.session_cookie, &value, self.session_ttl) {
response.headers_mut().append(header::SET_COOKIE, cookie);
} else {
return auth_provider::deny(StatusCode::BAD_GATEWAY);
}
response
}
}
impl auth_provider::Provider for Compiled {
fn id(&self) -> &'static str {
self.provider_id
}
fn method(&self) -> auth_provider::IntegrationMethod {
auth_provider::IntegrationMethod::Oidc
}
fn strip_identity(&self, headers: &mut HeaderMap) {
let names: Vec<_> = headers
.keys()
.filter(|name| {
let name = name.as_str();
name.starts_with("remote-")
|| name.starts_with("x-authentik-")
|| name.starts_with("x-auth-request-")
|| matches!(
name,
"x-forwarded-user"
| "x-forwarded-email"
| "x-remote-user"
| "x-remote-email"
| "x-user"
| "x-email"
)
})
.cloned()
.collect();
for name in names {
headers.remove(name);
}
}
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(async move {
if !self.is_redirect_host(context) {
return Err(auth_provider::deny(StatusCode::FORBIDDEN));
}
if let Some(encoded) = cookie_value(incoming, &self.session_cookie) {
if let Ok(session) = open::<Session>(&self.session_secret, encoded) {
if now().is_ok_and(|current| session.expires > current) {
return Ok(identity(&session, &self.issuer));
}
}
}
if method != Method::GET && method != Method::HEAD {
return Err(auth_provider::deny(StatusCode::UNAUTHORIZED));
}
let result = tokio::select! {
_ = shutdown.cancelled() => return Err(auth_provider::deny(StatusCode::SERVICE_UNAVAILABLE)),
result = tokio::time::timeout(self.timeout, self.initiate(context, cookies)) => result,
};
Err(match result {
Ok(Ok(response)) => response,
Ok(Err(error)) => {
tracing::warn!(provider = self.provider_id, %error, "OIDC login initiation failed");
auth_provider::gateway_failure(
self.provider_label,
GatewayFailure::Verification,
)
}
Err(_) => {
auth_provider::gateway_failure(self.provider_label, GatewayFailure::Timeout)
}
})
})
}
fn reserved_path(&self, path: &str) -> bool {
matches!(path, CALLBACK | LOGOUT)
}
fn serve_reserved<'a>(
&'a self,
request: Request<Body>,
context: &'a forwarding::Context,
shutdown: &'a CancellationToken,
) -> Option<auth_provider::Route<'a>> {
match request.uri().path() {
CALLBACK => Some(Box::pin(async move {
self.callback(request, context, shutdown).await
})),
LOGOUT => Some(Box::pin(async move {
if request.method() != Method::GET || !self.is_redirect_host(context) {
return auth_provider::deny(StatusCode::BAD_REQUEST);
}
let mut response = redirect("/", StatusCode::SEE_OTHER);
response
.headers_mut()
.append(header::SET_COOKIE, clear_cookie(&self.session_cookie));
response
.headers_mut()
.append(header::SET_COOKIE, clear_cookie(&self.state_cookie));
response
.headers_mut()
.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
response
})),
_ => None,
}
}
}
#[cfg(test)]
include!("tests.rs");
+242
View File
@@ -0,0 +1,242 @@
#[cfg(test)]
mod tests {
use super::*;
use ring::signature::{self, KeyPair};
fn enabled() -> Settings {
Settings {
enabled: true,
issuer_url: "https://auth.test.com".into(),
client_id: "rustproxy-app".into(),
client_secret: "super-secret-client".into(),
session_secret: "an-uncorrelated-high-entropy-session-secret-value".into(),
redirect_url: "https://app.example.com/_rustproxy/oidc/callback".into(),
..Settings::default()
}
}
#[test]
fn off_by_default_and_issuer_may_be_on_unrelated_domain() {
assert!(!Settings::default().enabled);
let settings = enabled();
assert!(settings.validate().is_ok());
assert_eq!(
https_url(&settings.issuer_url).unwrap().host_str(),
Some("https://auth.test.com")
);
assert_eq!(
https_url(&settings.redirect_url).unwrap().host_str(),
Some("app.example.com")
);
}
#[test]
fn invalid_configuration_is_rejected() {
let mut settings = enabled();
settings.issuer_url = "http://auth.example.com".into();
assert!(settings.validate().is_err());
settings = enabled();
settings.redirect_url = "https://app.example.com/other".into();
assert!(settings.validate().is_err());
settings = enabled();
settings.client_secret.clear();
assert!(settings.validate().is_err());
settings = enabled();
settings.session_secret = "short".into();
assert!(settings.validate().is_err());
settings = enabled();
settings.scopes = "profile email".into();
assert!(settings.validate().is_err());
settings = enabled();
settings.scopes = "openid \r\nuser".into();
assert!(settings.validate().is_err());
}
#[test]
fn encrypted_cookies_are_authenticated_and_not_readable() {
let secret = "session-secret-at-least-32-bytes-unique";
let session = Session {
subject: "a-stable-id".into(),
username: "alice".into(),
email: Some("alice@example.com".into()),
name: None,
groups: vec!["member".into()],
expires: now().unwrap() + 3600,
};
let encoded = seal(secret, &session).unwrap();
assert!(!encoded.contains("alice"));
let opened: Session = open(secret, &encoded).unwrap();
assert_eq!(opened.subject, "a-stable-id");
assert!(open::<Session>("wrong-session-secret-at-least-32-bytes", &encoded).is_err());
let mut corrupted = encoded.into_bytes();
let last = corrupted.len() - 1;
corrupted[last] = if corrupted[last] == b'A' { b'B' } else { b'A' };
assert!(open::<Session>(secret, std::str::from_utf8(&corrupted).unwrap()).is_err());
let header = cookie("__Host-rpoidc-test", "abc", 3600).unwrap();
let text = header.to_str().unwrap();
for expected in ["Path=/", "HttpOnly", "Secure", "SameSite=Lax"] {
assert!(text.contains(expected));
}
}
#[test]
fn cookies_cannot_be_reused_across_oidc_clients_or_redirect_hosts() {
let shared = "same-admin-configured-secret-for-multiple-applications";
let a = scoped_secret(
shared,
"https://auth.example.com",
"app1",
"https://app1.example.org/_rustproxy/oidc/callback",
);
let b = scoped_secret(
shared,
"https://auth.example.com",
"app2",
"https://app2.example.org/_rustproxy/oidc/callback",
);
assert_ne!(a, b);
let encrypted = seal(&a, &serde_json::json!({"sub": "alice"})).unwrap();
assert!(open::<Value>(&b, &encrypted).is_err());
}
#[test]
fn duplicate_cookies_cannot_override_a_valid_cookie() {
let mut headers = HeaderMap::new();
headers.insert(
header::COOKIE,
"a=1; login=ok; login=malicious".parse().unwrap(),
);
assert!(cookie_value(&headers, "login").is_none());
headers.insert(header::COOKIE, "a=1; login=ok".parse().unwrap());
assert_eq!(cookie_value(&headers, "login"), Some("ok"));
}
#[test]
fn userinfo_must_match_signed_subject_and_does_not_trust_unverified_email() {
let mut claims = token::Claims {
subject: "id-42".into(),
username: "id-42".into(),
email: None,
name: None,
groups: vec![],
};
assert!(token::merge_userinfo(
&mut claims,
&serde_json::json!({
"sub":"other-user", "preferred_username":"admin"
})
)
.is_err());
assert_eq!(claims.username, "id-42");
token::merge_userinfo(
&mut claims,
&serde_json::json!({
"sub":"id-42", "preferred_username":"alice", "email":"alice@example.com",
"email_verified":false, "groups":["users"]
}),
)
.unwrap();
assert_eq!(claims.username, "alice");
assert_eq!(claims.email, None);
assert_eq!(claims.groups, vec!["users".to_string()]);
}
#[test]
fn id_token_signature_issuer_audience_nonce_and_expiry_are_checked() {
let rng = SystemRandom::new();
let pkcs8 = signature::EcdsaKeyPair::generate_pkcs8(
&signature::ECDSA_P256_SHA256_FIXED_SIGNING,
&rng,
)
.unwrap();
let keypair = signature::EcdsaKeyPair::from_pkcs8(
&signature::ECDSA_P256_SHA256_FIXED_SIGNING,
pkcs8.as_ref(),
&rng,
)
.unwrap();
let public = keypair.public_key().as_ref();
let jwks = serde_json::json!({ "keys": [{
"kid":"key1", "kty":"EC", "use":"sig", "alg":"ES256", "crv":"P-256",
"x": URL_SAFE_NO_PAD.encode(&public[1..33]),
"y": URL_SAFE_NO_PAD.encode(&public[33..65]),
}] });
let claims = serde_json::json!({
"iss": "https://auth.example.com", "aud": "rustproxy", "sub": "id-42",
"nonce": "session-nonce", "iat": now().unwrap() - 2, "exp": now().unwrap() + 100,
"preferred_username": "alice", "email": "alice@example.com",
});
let header = serde_json::json!({"alg":"ES256","kid":"key1","typ":"JWT"});
let token = |claims: &Value| {
let data = format!(
"{}.{}",
URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()),
URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).unwrap())
);
let sig = keypair.sign(&rng, data.as_bytes()).unwrap();
format!("{}.{}", data, URL_SAFE_NO_PAD.encode(sig.as_ref()))
};
let jwt = token(&claims);
let verified = token::verify_id_token(
&jwt,
&jwks,
"https://auth.example.com",
"rustproxy",
"session-nonce",
)
.unwrap();
assert_eq!(verified.subject, "id-42");
assert_eq!(verified.username, "alice");
assert!(token::verify_id_token(
&jwt,
&jwks,
"https://other.example.com",
"rustproxy",
"session-nonce"
)
.is_err());
assert!(token::verify_id_token(
&jwt,
&jwks,
"https://auth.example.com",
"other-client",
"session-nonce"
)
.is_err());
assert!(token::verify_id_token(
&jwt,
&jwks,
"https://auth.example.com",
"rustproxy",
"wrong-nonce"
)
.is_err());
let mut expired = claims.clone();
expired["exp"] = serde_json::json!(now().unwrap() - 1);
assert!(token::verify_id_token(
&token(&expired),
&jwks,
"https://auth.example.com",
"rustproxy",
"session-nonce"
)
.is_err());
let mut wrong = claims.clone();
wrong["sub"] = serde_json::json!("another-user");
let forged_parts: Vec<_> = token(&wrong).split('.').map(str::to_string).collect();
let modified = format!(
"{}.{}.{}",
forged_parts[0],
forged_parts[1],
jwt.split('.').last().unwrap()
);
assert!(token::verify_id_token(
&modified,
&jwks,
"https://auth.example.com",
"rustproxy",
"session-nonce"
)
.is_err());
}
}
+264
View File
@@ -0,0 +1,264 @@
use anyhow::{bail, ensure, Context as _, Result};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use ring::signature;
use serde_json::Value;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Clone, Debug)]
pub(super) struct Claims {
pub subject: String,
pub username: String,
pub email: Option<String>,
pub name: Option<String>,
pub groups: Vec<String>,
}
fn field<'a>(value: &'a Value, name: &str) -> Result<&'a str> {
value
.get(name)
.and_then(Value::as_str)
.context("missing or invalid OIDC claim")
}
fn key_bytes(value: &Value, key: &str) -> Result<Vec<u8>> {
let encoded = field(value, key)?;
ensure!(encoded.len() < 2048, "OIDC JWK component too large");
Ok(URL_SAFE_NO_PAD.decode(encoded)?)
}
fn validate_signature(key: &Value, alg: &str, signed: &[u8], signature_bytes: &[u8]) -> Result<()> {
ensure!(
key.get("use")
.and_then(Value::as_str)
.is_none_or(|usage| usage == "sig"),
"OIDC JWK is not a signing key"
);
ensure!(
key.get("alg")
.and_then(Value::as_str)
.is_none_or(|declared| declared == alg),
"OIDC JWK algorithm mismatch"
);
match alg {
"RS256" => {
ensure!(field(key, "kty")? == "RSA", "OIDC key type mismatch");
let n = key_bytes(key, "n")?;
let e = key_bytes(key, "e")?;
signature::RsaPublicKeyComponents { n: &n, e: &e }
.verify(
&signature::RSA_PKCS1_2048_8192_SHA256,
signed,
signature_bytes,
)
.map_err(|_| anyhow::anyhow!("invalid OIDC signature"))?;
}
"ES256" => {
ensure!(
field(key, "kty")? == "EC" && field(key, "crv")? == "P-256",
"OIDC key type mismatch"
);
let x = key_bytes(key, "x")?;
let y = key_bytes(key, "y")?;
ensure!(
x.len() == 32 && y.len() == 32,
"invalid OIDC curve coordinates"
);
let mut public = Vec::with_capacity(65);
public.push(4);
public.extend_from_slice(&x);
public.extend_from_slice(&y);
signature::UnparsedPublicKey::new(&signature::ECDSA_P256_SHA256_FIXED, public)
.verify(signed, signature_bytes)
.map_err(|_| anyhow::anyhow!("invalid OIDC signature"))?;
}
_ => bail!("unsupported OIDC signing algorithm"),
}
Ok(())
}
pub(super) fn verify_id_token(
jwt: &str,
jwks: &Value,
expected_issuer: &str,
client_id: &str,
expected_nonce: &str,
) -> Result<Claims> {
ensure!(jwt.len() <= 16384, "OIDC ID token too large");
let mut parts = jwt.split('.');
let head = parts.next().context("missing JWT header")?;
let payload = parts.next().context("missing JWT payload")?;
let signature = parts.next().context("missing JWT signature")?;
ensure!(parts.next().is_none(), "invalid JWT encoding");
let header: Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(head)?)?;
let claims: Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload)?)?;
let alg = field(&header, "alg")?;
ensure!(
matches!(alg, "RS256" | "ES256"),
"OIDC JWT algorithm not allowed"
);
let kid = header.get("kid").and_then(Value::as_str);
let keys = jwks
.get("keys")
.and_then(Value::as_array)
.context("invalid OIDC JWKS")?;
ensure!(keys.len() <= 32, "too many OIDC signing keys");
let candidates: Vec<_> = keys
.iter()
.filter(|key| {
kid.map_or(true, |id| {
key.get("kid").and_then(Value::as_str) == Some(id)
}) && key
.get("alg")
.and_then(Value::as_str)
.is_none_or(|configured| configured == alg)
&& key
.get("use")
.and_then(Value::as_str)
.is_none_or(|configured| configured == "sig")
})
.collect();
ensure!(
candidates.len() == 1,
"OIDC signing key missing or ambiguous"
);
let signed = format!("{head}.{payload}");
validate_signature(
candidates[0],
alg,
signed.as_bytes(),
&URL_SAFE_NO_PAD.decode(signature)?,
)?;
ensure!(
field(&claims, "iss")? == expected_issuer,
"OIDC issuer mismatch"
);
let audience = claims.get("aud").context("missing OIDC audience")?;
let audiences: Vec<&str> = match audience {
Value::String(value) => vec![value],
Value::Array(values) => values
.iter()
.map(|value| value.as_str().context("invalid OIDC audience"))
.collect::<Result<_>>()?,
_ => bail!("invalid OIDC audience"),
};
ensure!(audiences.contains(&client_id), "OIDC audience mismatch");
if audiences.len() > 1 {
ensure!(
field(&claims, "azp")? == client_id,
"OIDC authorized party mismatch"
);
} else if let Some(azp) = claims.get("azp").and_then(Value::as_str) {
ensure!(azp == client_id, "OIDC authorized party mismatch");
}
ensure!(
field(&claims, "nonce")? == expected_nonce,
"OIDC nonce mismatch"
);
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let exp = claims
.get("exp")
.and_then(Value::as_u64)
.context("missing OIDC expiration")?;
ensure!(exp > now, "OIDC ID token expired");
let issued = claims
.get("iat")
.and_then(Value::as_u64)
.context("missing OIDC issuance time")?;
ensure!(
issued <= now.saturating_add(60),
"OIDC token issued in the future"
);
if let Some(nbf) = claims.get("nbf") {
ensure!(
nbf.as_u64()
.is_some_and(|start| start <= now.saturating_add(60)),
"OIDC token is not yet valid"
);
}
let subject = field(&claims, "sub")?.to_string();
ensure!(
!subject.is_empty() && subject.len() <= 256,
"invalid OIDC subject"
);
let username = claims
.get("preferred_username")
.and_then(Value::as_str)
.filter(|name| !name.is_empty() && name.len() <= 256)
.unwrap_or(&subject)
.to_string();
let optional = |key| {
claims
.get(key)
.and_then(Value::as_str)
.filter(|value| !value.is_empty() && value.len() <= 256)
.map(str::to_string)
};
let groups = claims
.get("groups")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.filter(|s| !s.is_empty() && s.len() <= 128)
.take(32)
.map(str::to_string)
.collect()
})
.unwrap_or_default();
let email = if claims.get("email_verified").and_then(Value::as_bool) == Some(false) {
None
} else {
optional("email")
};
Ok(Claims {
subject,
username,
email,
name: optional("name"),
groups,
})
}
pub(super) fn merge_userinfo(claims: &mut Claims, value: &Value) -> Result<()> {
ensure!(
field(value, "sub")? == claims.subject,
"OIDC UserInfo subject mismatch"
);
if claims.username == claims.subject {
if let Some(username) = value
.get("preferred_username")
.and_then(Value::as_str)
.filter(|name| !name.is_empty() && name.len() <= 256)
{
claims.username = username.to_string();
}
}
if claims.name.is_none() {
claims.name = value
.get("name")
.and_then(Value::as_str)
.filter(|name| !name.is_empty() && name.len() <= 256)
.map(str::to_string);
}
if claims.email.is_none() && value.get("email_verified").and_then(Value::as_bool) != Some(false)
{
claims.email = value
.get("email")
.and_then(Value::as_str)
.filter(|name| !name.is_empty() && name.len() <= 256)
.map(str::to_string);
}
if claims.groups.is_empty() {
if let Some(groups) = value.get("groups").and_then(Value::as_array) {
claims.groups = groups
.iter()
.filter_map(Value::as_str)
.filter(|name| !name.is_empty() && name.len() <= 128)
.take(32)
.map(str::to_string)
.collect();
}
}
Ok(())
}
+1
View File
@@ -28,6 +28,7 @@ fn disabled_cache_retains_reason_without_matching_force_rules_or_reparsing_heade
"tinyauth",
"authelia",
"authentik",
"oidc",
"sticky_cookie",
"request_upgrade",
] {
+4
View File
@@ -354,6 +354,10 @@ pub struct Vhost {
#[serde(default)]
pub authentik: crate::authentik::Settings,
#[serde(default)]
pub oidc: crate::oidc::Settings,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub authentication: Option<crate::auth_provider::Choice>,
#[serde(default)]
pub certificate_id: Option<String>,
#[serde(default)]
pub allow_certificate_mismatch: bool,
+13
View File
@@ -244,12 +244,25 @@ impl Vhost {
}
self.basic_auth.compile()?;
crate::auth_provider::Selection {
choice: self.authentication,
basic_enabled: self.basic_auth.enabled,
tinyauth: &self.tinyauth,
authelia: &self.authelia,
authentik: &self.authentik,
oidc: &self.oidc,
}
.validate()?;
if self.oidc.enabled {
let redirect = url::Url::parse(&self.oidc.redirect_url)?;
ensure!(
redirect.host_str().is_some_and(|host| self
.frontend
.hosts
.iter()
.any(|name| name == host)),
"OIDC callback host must be an exact Vhost hostname"
);
}
self.pages.validate()?;
self.transform
.resolve(&crate::transform::Settings::default())?;