v0.34.16
This commit is contained in:
1 parent
e364392659
commit
f27fbf6a30
28 files changed
+2000
-60
No files matched your search
+34
-1
@@ -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": {
|
||||
|
||||
@@ -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") },
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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");
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -28,6 +28,7 @@ fn disabled_cache_retains_reason_without_matching_force_rules_or_reparsing_heade
|
||||
"tinyauth",
|
||||
"authelia",
|
||||
"authentik",
|
||||
"oidc",
|
||||
"sticky_cookie",
|
||||
"request_upgrade",
|
||||
] {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
Reference in new issue
Block a user