diff --git a/AGENTS.md b/AGENTS.md index 782981c..cca5257 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # RustProxy source and documentation maintenance -Current source version: **0.34.15**. The Cargo package and binary are named `rust-proxy`. +Current source version: **0.34.16**. The Cargo package and binary are named `rust-proxy`. Before editing, read [README.md](README.md), [ROADMAP.md](ROADMAP.md), the relevant [documentation guide](docs/README.md) and the actual code and tests. Historical release experiments are not current specifications. Use the bundled `src/admin/openapi.json` for API routes and payloads; the source implementation is authoritative for runtime behavior. @@ -17,7 +17,7 @@ Before editing, read [README.md](README.md), [ROADMAP.md](ROADMAP.md), the relev - Keep provider implementations isolated under `src//mod.rs` and `tests.rs`; the request pipeline only sees the object-safe `auth_provider::Provider` trait and the single `CompiledFrontend.auth_provider` slot. Preserve existing Vhost JSON fields on upgrades. - Prefer the shared `forward_auth::Adapter` transport for compatible HTTP verifiers, and reserve protected-host callback routes through `reserved_path` / `serve_reserved` only when required. - Return fail-closed provider errors using `auth_provider::gateway_failure` and one `src/admin/web/error_auth_provider.html` template (with escaped provider names); never create a separate HTML file per vendor or expose backend diagnostics to clients. `AuthenticationResponse` prevents custom Vhost error pages from overriding auth errors/redirects, and `Identity` / `CookieSink` keep identities and cookies request-scoped. -- Update the Vhost selector, mutual-exclusion validation, config compiler, docs and provider tests together. Prove access/denial, redirects, header stripping, timeouts, and reserved routes with real verifiers before deployment. +- Keep the `authentication` provider/method selector in `auth_provider/choice.rs` separate from the shared OIDC integration transport. Extend the allowed provider/method combinations without duplicating OIDC protocol code. Accept legacy Vhost settings without a selector; when present, reject selectors inconsistent with enabled settings. Update the panel, mutual-exclusion validation, config compiler, OpenAPI, docs and tests together. Prove access/denial, redirects, header stripping, timeouts, and reserved routes with real verifiers before deployment. ## Engineering safeguards diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b6a66e..1209e57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,14 @@ # Changelog -Release history focused on **features, settings, default behavior, compatibility and fixes**. Changes to internal code structure and preliminary performance experiments are intentionally omitted. Historical descriptions reflect their respective releases; consult the current documentation for the settings available in 0.34.15. +Release history focused on **features, settings, default behavior, compatibility and fixes**. Changes to internal code structure and preliminary performance experiments are intentionally omitted. Historical descriptions reflect their respective releases; consult the current documentation for the settings available in 0.34.16. + +## 0.34.16 — optional OIDC central login (source candidate) + +- **Vhost → Authentication:** separates **Identity provider** (Tinyauth, Authelia, authentik, Pocket ID, custom OIDC, HTTP Basic) from **Integration method** (Forward Auth, OIDC, HTTP Basic). Pocket ID/custom support OIDC; Tinyauth/Authelia/authentik support Forward Auth or OIDC. Only one method can be active per Vhost. An optional `{ "authentication": { "provider": "authelia", "method": "oidc" } }` selector is validated against the existing provider settings. Omitted selectors retain legacy `*.enabled` behavior, including OIDC without an explicitly named vendor. Existing passwords, application callbacks, sessions and stored secrets retain their original keys and behavior. +- **Cross-domain login:** a protected application can use an unrelated HTTPS issuer with OIDC Authorization Code, S256 PKCE, transaction-bound state and nonce, and a fixed callback on the application hostname. Optional internal backend-origin routing supports identity providers running behind this same RustProxy without a circular public proxy dependency. The login provider keeps its own cookies; RustProxy issues a separate encrypted, host-only application session cookie. +- **ID token verification:** issuer discovery and same-origin JWKS retrieval, RS256/ES256 signature verification, issuer/audience/authorized-party/expiry/nonce checks, bounded HTTP responses and fail-closed provider errors. No access tokens are forwarded to application backends. Application identity is injected from verified token claims as `Remote-Sub`, `Remote-Issuer`, `Remote-User`, and optional name, email and groups headers after stripping forged client values. +- **Session policy:** HTTPS-only, HttpOnly, SameSite=Lax cookies; an independent random session secret per Vhost; one-hour default lifetime; callback at `/_rustproxy/oidc/callback` and local logout at `/_rustproxy/oidc/logout`. Invalid logins, expired cookies and unavailable issuer responses never authorize upstream access. +- **Administration:** new OIDC fields in the Vhost panel, matching OpenAPI schema and English/Polish labels. All OIDC values are disabled by default. Source candidate: the Rust build and live OIDC compatibility checks have not been performed in the packaging environment. ## 0.34.15 — optional Authelia and authentik forward auth (source candidate) diff --git a/Cargo.lock b/Cargo.lock index f4b733b..b9004c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1925,7 +1925,7 @@ dependencies = [ [[package]] name = "rust-proxy" -version = "0.34.15" +version = "0.34.16" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index 8eee141..3d0ccd7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rust-proxy" -version = "0.34.15" +version = "0.34.16" edition = "2021" rust-version = "1.99" description = "RustProxy - independent reverse proxy with local administration" diff --git a/README.md b/README.md index e146cd6..d190bb7 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,14 @@ -# RustProxy 0.34.15 +# RustProxy 0.34.16 RustProxy combines a reverse proxy, static/PHP web server, certificate management and traffic monitoring in one service with a browser administration panel. It supports several domains and backends without requiring a separate proxy configuration language. +## Changes in 0.34.16 + +- Separated **identity provider** from **integration method** in Vhost Authentication: Tinyauth, Authelia or authentik can use Forward Auth or OIDC; Pocket ID and compatible custom providers use OIDC. HTTP Basic remains its own method. The `authentication` field stores the pair, with legacy Vhost JSON still supported. +- Added optional OIDC Authorization Code + PKCE for a central HTTPS identity provider used by applications on unrelated domains. +- OIDC uses discovery, optional internal IdP routing, JWKS signature validation, nonce/state checks, encrypted host-only sessions and an application-host callback at `/_rustproxy/oidc/callback`. Other authentication integrations are unchanged. +- This is a source candidate; Rust build and real IdP integration tests are required before production deployment. An IdP also needs an OIDC client registered with the exact HTTPS application callback; ordinary Forward Auth configuration is not enough. + ## Changes in 0.34.15 - Added **Authelia** and **authentik** as separate, disabled-by-default Vhost authentication methods alongside existing Tinyauth and HTTP Basic Auth. Each Vhost selects at most one provider; older Vhost JSON imports keep their settings. @@ -20,7 +27,7 @@ RustProxy combines a reverse proxy, static/PHP web server, certificate managemen | Reverse proxy | HTTP/HTTPS origins, HTTP/1.1 and HTTP/2 upstream modes, health checks, load balancing, connection reuse, WebSockets, path-based routing, retries and error fallbacks | | Frontends | HTTP, HTTPS, HTTP/2, optional HTTP/3 (QUIC), named listeners, domain and wildcard routing, redirects, TLS certificates | | Web Server | Static content, downloads, directory/index policy, rewrite and `try_files`, selected `.htaccess` rules, optional PHP-FPM, application presets | -| Access | IP/CIDR and DNS/DDNS rules, country restrictions, reusable ACLs, rate limits, method rules, Basic Auth, Tinyauth, Authelia and authentik, security headers | +| Access | IP/CIDR and DNS/DDNS rules, country restrictions, reusable ACLs, rate limits, method rules, Basic Auth, Tinyauth/Authelia/authentik via Forward Auth, and optional OIDC with Authelia/authentik/Pocket ID/compatible providers, security headers | | Delivery | Optional shared HOT/RAM and COLD/disk cache, cache invalidation, compression, response transformations, static-file optimizations | | Integrations | Docker discovery via separate agents, TCP/UDP Streams, ACME certificate providers, Prometheus, Grafana/Nagios examples and administration API | | Operations | Dashboard presets, traffic analytics, live/stored logs, configuration history, encrypted backups and certificate reloads | diff --git a/SHA256SUMS b/SHA256SUMS index 0f5244a..fec5b81 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,12 +1,12 @@ 61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore 4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example 9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore -0fac1a1a51c2b28a35ab3ca5ca78bc622d923f6338d060ddd28b024e0c585c94 AGENTS.md -0add2dca4bcce8a7b52aedaa91b80cb828055d19df3236803a6cf6a10702bea9 CHANGELOG.md -9990725b3ad88f93526c87d09cebc2505b3b34db7dcdaa1fdc5e40e5352891a0 Cargo.lock -0b93f835f1a5edc78105fcd2c2fdae02d48badab667089ec67543a93dee80b9f Cargo.toml +92250158a2241c61d4c61aa840025fd5dce9aec3d406d7f199a8673c7ff0cebb AGENTS.md +d5655bd4146fda0ce345a8e7cf011f662275728d3d48e41561be6a7c4b7a454d CHANGELOG.md +3deb7f5702d36446f069cc9c5927bdcbfdc306887bec6f45fbc9507295b6c5c9 Cargo.lock +024fe1c84a50255b7b32ea12110285c60b6386daba02a57843324baee5ae94fb Cargo.toml 25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile -0230d86529bc60fa2db5ececff0ce3763fd44675f5ba3f48b6187bff27b474d6 README.md +801fe7dca4daa5b2ed5499490604fbd7d3cd3292f9985a77d2fe181270361d93 README.md e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ROADMAP.md da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml ac07b3bdd46c787989e93f55ebd6ea3d7f5b150ef36f2422e2b58df3f6b7543a build.rs @@ -27,9 +27,9 @@ d660f46f7ef8c539dd3f5486a31d5a1254b9737be8a278c7f9352c254558f8ac docker-agent/m e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/network.go 634790f35ff4627df2a78cec2ed371f6e9ec9df00afb386075595fc7f3ed72eb docker-agent/rustproxy-docker-agent.service 5220d669171ec3ad65f50e0bc8de5178dadaa4904def8e1a4f61002aa9e9a55b docker-agent/server.go -e34ce67e7105be1e3a666beaaf28338b6ce443c18a6cabdf9e54cb7e4ce61963 docs/ACCESS.md +26af69f0dca733c7659bfdb7c9e07f2521aaaa83913735d952a33d1bda85b95c docs/ACCESS.md 147755fb33e9e91098a4d2dfbe6df8b0d92d602e002d568e64a0f815641be2c2 docs/ACME_PROVIDERS.md -0489f81f1a766850b12c69a44b9fc4caef9a7441eccf4ed854df67d9989e7d41 docs/API.md +b359ca6b82fcb74f2ebdc59a68270d915c781586ea100e9598d4989815f21b2d docs/API.md 2362ab34020d4e0dcd70e280e9539ef7764b63212856ee08a3a55906c48f6421 docs/API_ACCESS.md 728b15b9825f547f34c4121219784798dd046fbbdd72ece8906d0cd77e8efc64 docs/BACKUP.md 3219aae15fd2e10157343ecdad8b175bd9735f39bccb31a5cb811553c13084dd docs/CACHE.md @@ -39,18 +39,18 @@ dc670d912a6a946160c2f8528c9bb710ec38ebe406dcb7dcaf18fb2653f667b6 docs/CUSTOM_LO e350c5081085b79e579d1500fa07feee94a709adaf6dfde7b3d9af124a78d184 docs/DASHBOARD.md 11a08bf2110bbeabc924eb049a30177a216af5607ccdaf8be9178e751f78a2c4 docs/DOCKER_DISCOVERY.md c891ee5272e2e47e1a8f97133a413e2330844103d543fb5cbcc4d0724d59a1a5 docs/INSTALL.md -27f48388891a040a4895428749d7e95fda620ab45ea3d68c536645a5bd5d89f2 docs/INTEGRATIONS.md +416535bb3ea88a0a7bd8dc8d019ed4254378eaec449ba66937e5ab7433c98d15 docs/INTEGRATIONS.md 694cb7b15c5bbdc94cecd15dc516c05dcb3467849322cdd125b872d36dee4699 docs/LOGS.md 888cdc8c56e10b8a7615012e192e085bf2899d0ec9b3ddfb5731e7de6ab5bcde docs/MIGRATION.md 27ba3ed337963d9cd05ba382666eed9c414f58c52e5897b4c1c45603f412b456 docs/MONITORING.md d5894c07c68ec4230987767198434abee13b0e525842fea21bcbe752fa82742b docs/PHP_FPM.md -8d29408a99926e3c2ae76f52b8a41c76b80447b953bcba4df242ea8e54bd0d5d docs/README.md +5ccd0cfe0af7e5290b0e6e7aa0e18f7b3fe4ff6cee64cc4acb836701e35396e2 docs/README.md 490a6dbe870f2b1fd4ec7816768dde6c86fa7957fc9de2d2f075e123c9222222 docs/RESPONSE_DELIVERY.md a7c5b01ad1e6aeb27bccf1085504854b70ed4bf020b481915aafcc4a8aeb1f54 docs/SECURITY.md bbef60ff1771da9f48af812987766e1e479408873ae2f079b13f1d5c62ecc6ff docs/STREAMS.md 6508819f181e2a935ffa117451915aa5568a31e2890c57fa10e04543db82f3e7 docs/TLS_POST_QUANTUM.md 43a2c6230f95f81bf4efd1575a4f7cdc8b02244df38e19b1ec9c3cd736d3afe6 docs/TRANSPORT.md -af1098e461c39c3f0ae09b8dbdbc4c1c362b2662f29c62602399de16f8912e97 docs/TROUBLESHOOTING.md +fd768ae0a5f55f3365b655490e592df9b3ca2399a82102533f6ec243364709cf docs/TROUBLESHOOTING.md e14c02f2c95daf5779d1aa5300751162b63fdd968832a1f0c2f191fa00ace6dc docs/VHOSTS.md 7e7eab2ba76c262e4000184f80f9a5cb69c7896fe7a046c0d3d84dc211c4b751 docs/WEB_SERVER.md 5502e8ce44276213803b66c58e3a4d804a1354f5ed5d83f47ee805e8c44ab088 examples/acl-headers.json @@ -59,6 +59,7 @@ e31ac05a99615e4ea6c4c9a8b2e090968583fe3959f838a600b5740cb170f8c5 examples/confi 39cad993003930febf98460311d185a278e7a9508b6b591299d9339d0f0c51cf examples/empty.json 695cc7621aa403148fcd0868f351b6e9ccc076fe35610e779af4a72f94d7d5f4 examples/http3-vhost.json b26833cb4251f9390a44192ecc827ff7f052fd19016a5ccf7498c94503ab4ce3 examples/mock-vhost.json +a6b835dc07defc12b8f0e48491d5f413843c8be2ba4501d7c28140a2822b034f examples/oidc-vhost.json 134d4a59727b27accb3141d4cee26a4a74157d236de4996bfd682e639462b2b5 examples/tinyauth-vhost.json 5a8f3a8a054abc89c6e78896b9fe2f903212199a24dc3d2d2bcbd0696a31bcde examples/transport.json d242de9614fbb52933b4accd32f4715ca94107abe4a3b1d7e088362b07a6ba60 examples/vhosts-balanced.json @@ -77,8 +78,8 @@ cb01fdef286f38b442dd9252c23c4f8e44eb72e9ecdd99adb926e8e9f6318bb1 integrations/r 96563a83e87cfb33e238c2a970ce36b1a1b51e91318d09b123c3755002ba2646 integrations/routeros-oci-build/build-routeros-oci.sh 3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh 13189fa546c3488dddd8efc80c47f6da5241e5fc28b79cbfe795414ea05c7a4f langs/README.md -956566ae2aff162cca55961bd1a92341746f0fb8e7187d8167bcbd06677a0c2c langs/en_US.json -9d9385ca67e5fd01f3b27b21d5515adc87ecda53129cda71130bff00f809157a langs/pl_pl.json +fb2917ab236d405d54eb36314c5250b0798c4714187e49b30332f55c02b4abac langs/en_US.json +e8385ab5709c4b0e32040caa1dfd25d76c6079953fa16d54fb7f3ed2e4548aa4 langs/pl_pl.json a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql 20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql @@ -149,7 +150,7 @@ d58a2ed83319176de4e66790ed292447c9497ae031464ec55cfe27174acb639a src/admin/file 282352eda7957477e7da44f16b90984b26a4a67a8755833bc0a6b34eaf93e3af src/admin/file_logs_query.rs 444d7715406ffd2305a72decf7fdf633aa4c2752cd881c8fa3b95ed971d79820 src/admin/logging_api.rs 243f4a9c3fa99781e19c3952354d2632c0ce65e1dafac729567cbe3c177f94cb src/admin/mod.rs -8ac7bd4270241e1a9834146cd9ace181a1facb72e70c1837a10d07c261bf7fde src/admin/openapi.json +b95553b4b13696704c7530e335aebb39c39c46ab2e534b49447f4458f694f76f src/admin/openapi.json 1f4ec45d146e00ef664fae37517f8fcb5455d47daec496c02b2fc15ddec49131 src/admin/operations.rs 257d93a29d0bda96462bcf695aba8d0ec0f9412c379e066ec46ae8ee034e3a53 src/admin/panel_assets.rs f5127855c2994d052678865df36d703b74f4a0afc633daf7718b10da18d26e37 src/admin/panel_content.rs @@ -242,11 +243,11 @@ a8afc6e70950bc70b98725eb049d73cff35dc3a3ee659229a15a8e08d3eb4f12 src/admin/web/ d290c048450655b3eeae7c6ec54a82d645d1965513694ab29fdf7eef796a1abd src/admin/web/traffic-panel.js c7e0ca45c19498d40ef6f4122de23f0e73094e905d39f798942f70d15b9cf86e src/admin/web/transform-panel.js a55f45dad5b540def419f8487161fb506b7e52744c76a5e661c7a5fa0a98a426 src/admin/web/vhosts/core.js -d4e447c176a72a6f803f84c6a6925c4905acbcd31ea5c9bf7bc3ed9e0f455054 src/admin/web/vhosts/editor.js +aaefda612ab04860f7c5a16f42a400fdd229277606237fb38eace1aa2fcd436a src/admin/web/vhosts/editor.js 8338ce13b5e3e3f421437631124cb691aeb5b9e17409d256b4b9bcac457b3c59 src/admin/web/vhosts/health.js b3239e67901f1796d7a974ade0796f2e3590ff8064628a5629b78c7f6ce2f32e src/admin/web/vhosts/protocol.js 8c3da49973c62333559b0e3588c02beb0b425c025eac5cfa82a1bb968f70d9dd src/admin/web/vhosts/rates.js -cbfcaff4f83ed3058556ed8247e5d9f1ee16aee1772dde770c7bdacb699dc5d6 src/admin/web/vhosts/render.js +35ffa706ecfc2b25a5973583496c57173b91958ae82377832292714786f2125a src/admin/web/vhosts/render.js 4dfdeb7c73c2775eacaca65fd8ef1dc80b275421b15522d319d338f5b96288cc src/admin/web/web-server-profiles.js dafce3a59a6007e59d7a4018fce96a14cd8e8cedb3ce77f02ee229a280c122d8 src/admin/web/web-server.js 2d40c2cb32d7365df0c9cd6c8cb6f0483c7a799786c648f4836a0d9740c6c5ef src/auth/files/mod.rs @@ -254,8 +255,9 @@ b2f948bcc3dbd24952c7b1b02d41d06757f49f0c38a78caf51f8f8a687d8480d src/auth/files 50ef648351271d78310e00caa6d246e0009171ed036a3e4e1144737df8c064c2 src/auth/mod.rs 25b9f78a7eee277902d375266bd0f023b79794bcdf646214fc161c5e6dab72d1 src/auth/store.rs 745226cabb007819370fc6aa680995a03a8a13fa057dacfab0fcc21d0c43bbe4 src/auth/tests.rs +0a4d6a10462d3b2895126161aec308bdad134071765e508408de047e69f0376b src/auth_provider/choice.rs 514e99e0d3da21abd633d53872234bc4d7d0178583ab291e53cf4b1802fd5337 src/auth_provider/errors.rs -fffd302d2b4f5fa6575a09eae428445403375c5f26c29e4b4818f5f263918637 src/auth_provider/mod.rs +f0da85c578db94af8dd4db4724a712f52b17f998b63d5a937ef8d86d2f222f2b src/auth_provider/mod.rs 72a6e245cef078585bc39932033c05cd866ee518e9cb4e7b89bca87ce941e971 src/auth_provider/testing.rs 59e1af728770d28265198844b3dd5740328bcb1df96d170aae575d3d97ac42d8 src/authelia/mod.rs f44b10fd3286b805008f746bee8164bd9c3ec7ebc86724314408f6703d6e1f41 src/authelia/tests.rs @@ -325,9 +327,9 @@ f4df024acdb24be450822e58108af23db3dc82bf1f77531cd16fe340fc01062c src/config/com fbea01fea1213d98aea812c1b78d5db289e09200b98ae514d742235cd3fa70e1 src/config/internal_routing.rs 101253b4c9275e38dc7e1088051d6cd55ae7616213eb1c02e3e1c5ffc6fea4a0 src/config/model.rs 75839458ddbc7480ec4492a95eeb67b617ba7d62f7b67e7a767a3453b74dbcbc src/config/route_policy_tests.rs -f9db8dc17edadb1d549fed8b10f858a0bf1d0d51802df7d558f7bbf33c1c1486 src/config/routing.rs +3c0ede1417e83720b010efa670fe8494e3ad0e9138065d725cbb9c969de6c559 src/config/routing.rs dd9a6ad8f58e0cdd1ae74f3f972fee0ac14383cd8b8f518403b9a36c169f35bd src/config/snapshot.rs -094dc6688d5bd0e8bef92f17736bad740a95b4765cd29fe89d5362d155e868c5 src/config/tests.rs +9d2fdb3f4c9d4117a6766e56ebe6e1e34687d40100728de98f47e9fce3fe3aa5 src/config/tests.rs f35de0ee1516ece6bbfd2b2a7db7d1e0f5048b0ec47cf2a3765dffb799abfd93 src/config/transport_tests.rs e0f46d885ed81ce306a110560cdd2c217dadde054986d89fcec835ebf6939d65 src/db.rs c290d0cb02dfa5d2c60ecabac1cfb20c8884e3d14cca1944906c6d8e677f1784 src/db/api_tokens.rs @@ -362,7 +364,7 @@ cfc75486e69c310fa6e7bf0448ed39a20cdad9dd8afe75e01592fc7c76a380db src/geoip/test 49ce4452ef7973cafa2e4071e2e14be0e16000e931a6501e4dc613652e49e230 src/http3/identity.rs 0b41072f6bcf28725c80f471584cd55d4900424ce5d55ac7f6909813788c63fd src/http3/settings.rs 5c550075c7b0fe687ad02095c1cad57b46b579fe21dc5371525b6c77db459886 src/http3/tests.rs -894bd4b19323d28969d53b1fd9e095cdb4a7a54ee3b6d9e4a41f4fe1d1eaf757 src/lib.rs +06c072cad074030fb0e7ad0e2fbb19358ac1e552edce8c969e6cee501a854d27 src/lib.rs e8399753a6ff49b7de19edb0a268bf871c9979a518354160cf07b4669051fcbb src/logging.rs e4be3ad758c3d93ce52dd3b1ab1a87810e5226339400ed997607a5b746354751 src/logging/error_runtime.rs b2ed730006fdc080cf7eafc6d20bb89661c639631c89642d110e0c1319e431b5 src/logging/format.rs @@ -383,6 +385,9 @@ f27f1f8bed769883d1a7ae6a48f89f7712394dfab65d05702b0f944f0a45bd8e src/metrics/vh ee401f943bd8b2706c33419ed69102269c08dcb4ceba6f4be2ac0861115874a7 src/metrics/vhost/tests.rs 9c96c3e4edb20dd0c36b5c373f1925dd60368e75853002206523072d418d5417 src/not_found/mod.rs fa2fc6c6987a8356a9da92cade10db62426d8863e9cef3c0d4324c45780c5b33 src/not_found/tests.rs +afb91130ba3312cb5c0251ffdf78e0cb6dcee823ca4de2a87d34cbc08466be1a src/oidc/mod.rs +cda94d19955f427bdbf64b820a691a03505d65c4017477a84c1c0bbb583417f4 src/oidc/tests.rs +aec2c4124d99fc474c2020501a3b9ba866835de9260fc8dcc9d0c5d61c67b223 src/oidc/token.rs d32d757ace8753eccb842c24b7af77061141478d10fe0acdfb05263d19e9a64c src/pages/mod.rs a3474253e83d1548bdafcb696a80f064283062196d665bb6316952355d54b785 src/pages/tests.rs 5650a3f70162abd97021e0a51a92cac2c4dd39004426ef99dff36cbb0741aeac src/prometheus.rs @@ -400,7 +405,7 @@ e97894a7ca8a3c4dd753c8d4d1bff20cfa50f8380d8864b8cd4197241c018040 src/proxy/cach 78caad08c548386958f605f82f6c4299ab94a910a1332a1e0817dc53920c96a3 src/proxy/cache_http_invalidation.rs a86b10ce0707b51e925e11ea14f7f40fa108a196166f62cf40007a97f9e17203 src/proxy/cache_http_invalidation_tests.rs a03b1735daa8fa8e40cf5bd9c6e200d211baf19c30f62522d4237ed56642bce7 src/proxy/cache_plan/mod.rs -62da703feff7e51bddc8998cb4d459f3ddbb2c109874285fa27fc920030b9129 src/proxy/cache_plan/tests.rs +811a00b55b24638b4bf8a6bea9ed7c0ffe2a758f76bfe63a95e1d31bce1c2c9c src/proxy/cache_plan/tests.rs 3ba479d72d0e94263acaf2eb84f4de0ff2b0b455ee6a4d3f04bb869a5a30a103 src/proxy/error_fallback.rs aacc3249b3cbce0726dc16e28ee09d78efc4ac89103675287b1adab870bb4e15 src/proxy/forward/cache_fast_path_tests.rs f7903200756dc6393392560656b23364f0d444d6357d264713b17c63fd8b636b src/proxy/forward/cache_lookup.rs @@ -506,10 +511,10 @@ ae880b40197454ea3fbf4d2113984dc372098b2e54b97e87c83cc319de253377 src/upstream/t 696a2783a24f7f3d69fb820dc3a772ec49ea46ee017087952a8253363a9b7374 src/user_agent/tests.rs d61c427c1e9cf457f1232b822d73e846e9f329cfbe61215e7ca061d53762155d src/vhost.rs 846707452ccf10962f1763be66b8f56c050b0398b48968015fb799dac289c0d6 src/vhost/error_fallback.rs -4147d7afbb8f518e774e966a98422b8c03f32a1e792fdf518c0d4e50ded6dcd6 src/vhost/model.rs +f0fed03e2067cc1d5fd081bd33ea90621e5012d99b76172c3d45c29a80d22f73 src/vhost/model.rs fc8af7342a3b147921e9040116af5dfe680b3df910db2416ada18198bbeea7df src/vhost/redirect.rs dd6e121445402a2ac8744f0e4f23d879b4356f47c1bb00737bd15ba459661978 src/vhost/tests.rs -5e48267e62301a2fa3e5a6e9da49223949e73b4ae518fb77d0a62a44a6a8c6d5 src/vhost/validation.rs +844b7170335403db30100af18eff182e0cfac327091a2fad466db6ace57c42f5 src/vhost/validation.rs 8d1eff4b24f01d137414ef5419f75dd281879228adb70409645b11b3c78c20e9 src/web_server/config.rs 9744e8c29ae72a4d9fbc9a76166a97f8292c4c312295ea40d1cb699e03d80a9d src/web_server/config/compile.rs 5ed79fa5b9c53f95ca200d89a9ba927dda816b61fbdf3b852bd3c9670e3d2d4f src/web_server/config/model.rs @@ -536,6 +541,7 @@ da7c69cd4b26289170d6d89ae3a6d41488fdca3f184cd8f63a160217fa26e49d src/web_server b791762b4ab7a43c3be19df84e523faf3dfb4df055f66d02178f93edb60d2204 src/web_server/serve.rs 642ac020745d963554ab382aa755b8f24d140f5f6b8ea8e7c74da59614f1cfe0 src/web_server/tests.rs f00743af2cf4a5952caef7a1dcf68e7817b5599a252a9973e1c22045517c0b0e tests/fixtures/argon2id.json +e24a0f84f1bfc46964df865ac387d651f7b384d781cbc8f3a9a5b0b60f3d69ca tests/vhost_auth_selection.js eca68a07c2b1e45fdb8f512294f5a432db3ae5a6aec8fb200b47f155cd9a31d6 tools/README.md 7c30c280d0b0a8bf855eeffab583ca34dc161255f7ef6124148aa979c17c26d3 tools/dev/memory_load.py d1418f8e574a7390fd45a2aa879bdf5720b773162d65e295ab0350506aa5220a tools/dev/mock_auth.py diff --git a/docs/ACCESS.md b/docs/ACCESS.md index aa1c0d1..615893e 100644 --- a/docs/ACCESS.md +++ b/docs/ACCESS.md @@ -19,7 +19,7 @@ Global restrictions add to per-vhost and path rules. Rate-limit requests are cou Default deny plus explicit allow exceptions is usually safer for private admin applications. Apply rules to a staging client before enforcing them on a live business-critical service. -In the **Vhost → Security** editor, Traffic policy groups reusable ACL profiles and the separate **inline Vhost IP/CIDR access list** together, followed by HTTP methods, request Content-Type filtering, per-IP rates, country policy, country-rate and User-Agent-rate controls. The inline list is not a second version of an assigned profile: both layers remain independently enforced; an allow in one does not cancel a deny in the other. The **Vhost → Authentication** tab selects exactly one of Basic Auth, Tinyauth, Authelia and authentik. HSTS, security headers and User-Agent blocking are separate Security groups. **Custom locations → Access** separates its ACL profile, HTTP method, country and inline IP/CIDR fields. These are only layout changes; restriction scopes still combine as described above. +In the **Vhost → Security** editor, Traffic policy groups reusable ACL profiles and the separate **inline Vhost IP/CIDR access list** together, followed by HTTP methods, request Content-Type filtering, per-IP rates, country policy, country-rate and User-Agent-rate controls. The inline list is not a second version of an assigned profile: both layers remain independently enforced; an allow in one does not cancel a deny in the other. The **Vhost → Authentication** tab first selects an identity provider, then a compatible integration method (Forward Auth, OIDC or Basic). A Vhost still uses only one active method; existing JSON settings are retained for compatibility. HSTS, security headers and User-Agent blocking are separate Security groups. **Custom locations → Access** separates its ACL profile, HTTP method, country and inline IP/CIDR fields. These are only layout changes; restriction scopes still combine as described above. ## Content-Type request denylist @@ -29,22 +29,32 @@ Ordinary Vhosts can reject selected MIME types under **Vhosts → Security → R Method policies allow, block or leave methods unrestricted. A denied method returns `405`, while typical ACL/country denials return `403`. Per-client rate limits can configure request count, window and burst with optional country overrides; exceeding the limit returns `429` with `Retry-After`. Limits are application controls, not a replacement for upstream DDoS protection. -## Basic Auth, Tinyauth, Authelia and authentik +## Identity providers and authentication integration methods + +Choose **Identity provider → Integration method** in **Vhost → Authentication**. Tinyauth, Authelia and authentik support both **Forward Auth** and **OIDC** when the installed identity service has the relevant endpoint enabled. Pocket ID and other generic OpenID Connect issuers use **OIDC** only. Built-in HTTP Basic remains available. Forward Auth uses the vendor-specific settings object and transport, while OIDC uses the shared `oidc` settings and an exact application callback. The protected app does not need to implement OIDC. + +New Vhosts store an explicit `authentication` choice; old Vhosts with only `tinyauth.enabled`, `authelia.enabled`, `authentik.enabled`, `basic_auth.enabled` or `oidc.enabled` still load. An older OIDC configuration without a named provider appears as **Other OIDC provider**, retaining its issuer and client settings. The selected pair must agree with the enabled settings (e.g. `authentication: {provider: authelia, method: oidc}` requires `oidc.enabled: true` and `authelia.enabled: false`). Conflicting choices are rejected, not silently downgraded to an unprotected Vhost. + +### Existing integrations - **Basic Auth:** protect selected Vhosts with configured credentials. Use HTTPS so credentials are not sent over plaintext. - **Tinyauth:** add a verification URL and an HTTPS login URL. RustProxy makes a bounded, body-free authorization request and can pass specifically allowed identity headers upstream. An origin URL automatically uses `/api/auth/nginx` (or set that path explicitly). This Nginx auth-request integration sends `X-Original-URL` and `X-Original-Method`, with a validated `X-Forwarded-For` and `X-Real-IP` for Tinyauth IP ACLs. It does **not** send `X-Forwarded-Host` or `X-Forwarded-URI` to the verifier, avoiding the `conflicting auth module headers` rejection introduced in Tinyauth 5.2.0 while keeping compatibility with earlier versions that implement the Nginx endpoint. Tinyauth has its own timeout and private-backend policy. - **Provider-neutral gateway error pages (0.34.15):** One embedded `src/admin/web/error_auth_provider.html` template renders a self-contained HTML page for **Tinyauth, Authelia, authentik, and future providers**, using only the safe provider display name and the failure category. It replaces the three vendor-specific `error_tinyauth_*.html` source files while preserving the familiar Tinyauth response titles and statuses. For example: `502 Tinyauth connection error` if its HTTP client cannot reach Tinyauth (DNS/TCP/TLS/transport), `504 Tinyauth timeout` if the auth deadline expires, or `502 Tinyauth verification error` for unexpected verifier status or invalid/oversized data. The page always retains the HTTP error status, `Content-Type: text/html; charset=utf-8`, `Cache-Control: private, no-store` and `X-Content-Type-Options: nosniff`. Backend URLs, protocol errors and identity data are not shown to visitors. All of these cases **deny access** to the protected origin. HTTP `401`/`403` and configured login redirects remain unchanged. These auth-specific pages take precedence over custom Vhost/general HTTP error pages so the failure remains distinguishable. The generic failure marker also protects Basic Auth denials and provider login redirects from being replaced by Vhost pages. HTTPS-verifier transport errors are classified as connection failures, truncated/oversized/invalid verifier responses as verification failures, and verification deadlines as timeouts. - Point the verifier URL **directly** at the private Tinyauth service (for example `http://tinyauth:3000/api/auth/nginx`), rather than routing it through a second public proxy that might inject conflicting headers. Do not disable Tinyauth's header-spoofing protections. Confirm that Tinyauth trusts only the actual RustProxy address/network when enabling Tinyauth IP-based access controls (`TINYAUTH_AUTH_TRUSTEDPROXIES`). -- **Authelia (0.34.15):** select **Authelia** under **Vhost → Authentication**. Enter the internal service origin (for example `http://authelia:9091`) and the public HTTPS login portal (for example `https://auth.example.com`); RustProxy calls `/api/authz/forward-auth`. It forwards trusted `X-Forwarded-*`, `X-Original-URL` and `X-Original-Method` metadata, verifies the provider result, returns provider-approved `302/303` login redirects only to the configured portal origin, and forwards configured `Remote-*` identity headers after stripping client-forged values. A `200` verifier response alone grants access. `401`/`403` deny; transport failures return 502 and timeouts 504. The Authelia `session.cookies` domains and `authelia_url` are defined in Authelia itself: protecting **unrelated registered domains** generally requires a login URL/cookie configuration within each domain, not one cookie from a single unrelated site. -- **authentik (0.34.15):** select **authentik** and enter the private outpost origin (for example `http://authentik:9000`), not an `/if/flow` login URL. RustProxy calls `/outpost.goauthentik.io/auth/nginx`, forwards verified `X-Authentik-*` identity headers, and responds to a `401` with a same-origin login start at `/outpost.goauthentik.io/start?rd=...`. RustProxy reserves `/outpost.goauthentik.io` (and descendants) on the protected application hostname and forwards them to the configured outpost **without checking authentication again**, after ordinary global/Vhost IP/country/rate rules. No manual Custom Location is needed for that path. In authentik create a separate **Proxy Provider → Forward auth (single application)** for each protected external application URL, assign it to the serving outpost, then enable that Vhost's integration. Multiple unrelated domains can share the same authentik installation; they do **not** share a browser cookie across domains. -- **Adding another provider:** Keep the new vendor's `Settings` and `Compiled` implementation under `src//mod.rs` and its tests under `src//tests.rs`. Implement the `auth_provider::Provider` trait for runtime authorization, optional `reserved_path`/`serve_reserved` hooks for same-host callbacks, and (for Nginx-compatible providers) the shared `forward_auth::Adapter`. Add an enabled-by-default-false Vhost settings field, validate mutual exclusivity in `auth_provider::Selection`, compile it into the **single** `CompiledFrontend.auth_provider` slot, and register the method in the panel. Reuse `auth_provider::deny` / `auth_provider::gateway_failure` instead of adding separate HTML templates. Existing `tinyauth`, `authelia`, and `authentik` configuration keys remain backward compatible. -- **Per-provider security:** The verifier/outpost connection uses TLS certificate verification for HTTPS with optional additional CA, explicit private-backend opt-in and the existing safe-address/local-listener restrictions. Only allowlisted response identity fields and bounded session cookies reach applications/browsers. Inaccessible, malformed or timed-out authorization services **deny** origin access. Both Authelia and authentik are disabled by default; old Tinyauth settings are retained when inactive. Only one of the four authentication methods can be enabled for one Vhost. Protected responses bypass shared caching. Confirm both allowed/denied requests, logins, sessions, redirects and WebSockets with the actual provider before rollout. +- **Authelia (0.34.15):** select **Authelia → Forward Auth** under **Vhost → Authentication**. Enter the internal service origin (for example `http://authelia:9091`) and the public HTTPS login portal (for example `https://auth.example.com`); RustProxy calls `/api/authz/forward-auth`. It forwards trusted `X-Forwarded-*`, `X-Original-URL` and `X-Original-Method` metadata, verifies the provider result, returns provider-approved `302/303` login redirects only to the configured portal origin, and forwards configured `Remote-*` identity headers after stripping client-forged values. A `200` verifier response alone grants access. `401`/`403` deny; transport failures return 502 and timeouts 504. The Authelia `session.cookies` domains and `authelia_url` are defined in Authelia itself: protecting **unrelated registered domains** generally requires a login URL/cookie configuration within each domain, not one cookie from a single unrelated site. +- **authentik (0.34.15):** select **authentik → Forward Auth** and enter the private outpost origin (for example `http://authentik:9000`), not an `/if/flow` login URL. RustProxy calls `/outpost.goauthentik.io/auth/nginx`, forwards verified `X-Authentik-*` identity headers, and responds to a `401` with a same-origin login start at `/outpost.goauthentik.io/start?rd=...`. RustProxy reserves `/outpost.goauthentik.io` (and descendants) on the protected application hostname and forwards them to the configured outpost **without checking authentication again**, after ordinary global/Vhost IP/country/rate rules. No manual Custom Location is needed for that path. In authentik create a separate **Proxy Provider → Forward auth (single application)** for each protected external application URL, assign it to the serving outpost, then enable that Vhost's integration. Multiple unrelated domains can share the same authentik installation; they do **not** share a browser cookie across domains. +- **OIDC (0.34.16, opt-in):** choose **Authelia → OIDC**, **authentik → OIDC**, **Pocket ID → OIDC** or **Other OIDC provider → OIDC** for the protected Vhost. This switches to the shared OIDC implementation instead of the vendor's Forward Auth adapter. Provide the exact HTTPS **Issuer**, Client ID and confidential Client Secret, a strong unique Session Secret (`openssl rand -hex 32`), and `https://YOUR-EXACT-APP-HOST/_rustproxy/oidc/callback` as the **Redirect URL**. Register that exact callback as an allowed redirect URI in the identity provider. The issuer can be `https://auth.gruszczynski.cc` while the protected hostname is `app.linuxiarz.pl`; browser cookies do not cross between them. The issuer's discovery `/.well-known/openid-configuration`, authorization, token and JWKS endpoints must resolve from RustProxy and use the issuer's HTTPS origin with trusted TLS. Use split DNS or the private-address opt-in if the issuer resolves to internal addresses; do not disable HTTPS certificate validation. OIDC currently requires a confidential client using `client_secret_basic`, authorization-code flow, and an RS256 or ES256 signing key. Public PKCE-only clients are not supported. +- **OIDC session and identity:** RustProxy generates short-lived transaction state with S256 PKCE and nonce; requires a signed ID token with exact issuer, audience, validity and nonce; and encrypts a separate `__Host-` session cookie for the application host (default 3600 seconds, max 86400). The session is **not continuously revalidated with the issuer**; removing a user at the issuer does not invalidate an already-issued RustProxy session before its expiry or local logout (`/_rustproxy/oidc/logout`). Successful login injects `Remote-Sub`, `Remote-Issuer`, `Remote-User` and optional `Remote-Email`, `Remote-Name`, `Remote-Groups`; use `(Remote-Issuer, Remote-Sub)` for stable account identity and do not auto-link accounts solely by email or username. The backend must not be directly accessible from untrusted clients. Non-GET/HEAD requests without a valid session receive 401, and authentication endpoint failures return 502/504. The provider URL must not itself be protected by this Vhost's OIDC integration. For Authelia, enabling its Forward Auth portal alone is insufficient: also configure Authelia as an OIDC issuer under `identity_providers.oidc`, with at least one signing key, and register a confidential client for every callback URL (or separate clients per application). OIDC does not require any special support from the application being proxied, only from the remote identity provider. + +- **Adding another provider:** Register the vendor and supported methods in `auth_provider::choice::IdentityProvider` and in the Vhost editor. OIDC-compatible vendors reuse the shared `oidc` integration and may need no new Rust transport adapter. New Forward Auth adapters belong in `src//mod.rs` with tests and may reuse `forward_auth::Adapter`; their compiled implementation implements the object-safe `auth_provider::Provider` trait. Runtime request handling continues through one `CompiledFrontend.auth_provider` slot and provider-neutral `error_auth_provider.html`. Preserve legacy Vhost configuration keys; add new providers without copying OIDC protocol code or HTML error templates. +- **Per-provider security:** The verifier/outpost connection uses TLS certificate verification for HTTPS with optional additional CA, explicit private-backend opt-in and the existing safe-address/local-listener restrictions. Only allowlisted response identity fields and bounded session cookies reach applications/browsers. Inaccessible, malformed or timed-out authorization services **deny** origin access. Both Authelia and authentik are disabled by default; old Tinyauth settings are retained when inactive. Only one integration method can be enabled for one Vhost. Protected responses bypass shared caching. Confirm both allowed/denied requests, logins, sessions, redirects and WebSockets with the actual provider before rollout. ### Minimal Vhost configuration examples (inside a Vhost object) -Authelia: +Authelia with Forward Auth: ```json +"authentication": {"provider": "authelia", "method": "forward_auth"}, "authelia": { "enabled": true, "url": "http://authelia.internal:9091", @@ -53,9 +63,10 @@ Authelia: } ``` -authentik (same URL for the outpost check and its reserved public path): +authentik with Forward Auth (same URL for the outpost check and reserved public path): ```json +"authentication": {"provider": "authentik", "method": "forward_auth"}, "authentik": { "enabled": true, "url": "http://authentik.internal:9000", @@ -63,7 +74,24 @@ authentik (same URL for the outpost check and its reserved public path): } ``` -Set `basic_auth.enabled`, `tinyauth.enabled`, and the other provider's `enabled` to `false` for these examples. These are fragments, not whole Vhost documents. Protect the public provider portal with a separate Vhost **without** forward auth to avoid recursive redirects. +For central login from an unrelated application domain, select **Authelia → OIDC** and use the following Vhost fragment (also register this callback with the issuer): + +```json +"authentication": {"provider": "authelia", "method": "oidc"}, +"oidc": { + "enabled": true, + "issuer_url": "https://auth.gruszczynski.cc", + "backend_url": "http://10.0.0.20:9091", + "redirect_url": "https://app.linuxiarz.pl/_rustproxy/oidc/callback", + "client_id": "app-client-id", + "client_secret": "CHANGE_ME_CLIENT_SECRET", + "session_secret": "CHANGE_ME_RANDOM_SECRET_AT_LEAST_32_BYTES", + "scopes": "openid profile email", + "allow_private": true +} +``` + +The `client_secret` and `session_secret` are illustrative placeholders and must be replaced with generated values. Disable other authentication settings for this Vhost. Set `basic_auth.enabled`, `tinyauth.enabled`, and the other provider's `enabled` to `false` for these examples. These are fragments, not whole Vhost documents. Protect the public provider portal with a separate Vhost **without** forward auth to avoid recursive redirects. Do not use a User-Agent blocklist as the primary access control: HTTP clients can forge their User-Agent freely. diff --git a/docs/API.md b/docs/API.md index f317c1d..67199c5 100644 --- a/docs/API.md +++ b/docs/API.md @@ -78,7 +78,7 @@ The section name in the URL and its corresponding JSON member in a full configur | `logging` | `logging`, `error_log` | | `global-security` | `global_security`, including `user_agent_blocking` | -Every GET returns a `value` object or array whose shape can be sent unchanged to the matching PUT. For the three grouped sections the `value` contains exactly the named members; for the other sections it is the serialized value of the matching configuration member. Saving does not alter unrelated sections. In 0.34.15 each ordinary Vhost may also configure mutually exclusive `authelia` and `authentik` objects alongside the existing `tinyauth`/`basic_auth` settings; both new providers default to disabled. See [Access](ACCESS.md). +Every GET returns a `value` object or array whose shape can be sent unchanged to the matching PUT. For the three grouped sections the `value` contains exactly the named members; for the other sections it is the serialized value of the matching configuration member. Saving does not alter unrelated sections. In 0.34.16 an ordinary Vhost may configure mutually exclusive `oidc`, `authelia` and `authentik` objects alongside existing `tinyauth`/`basic_auth` settings; every optional provider defaults to disabled. OIDC uses `/_rustproxy/oidc/callback` on an exact Vhost HTTPS hostname and requires issuer/client/session secrets. See [Access](ACCESS.md). ### Complete section-update example diff --git a/docs/INTEGRATIONS.md b/docs/INTEGRATIONS.md index 5755875..c03fda3 100644 --- a/docs/INTEGRATIONS.md +++ b/docs/INTEGRATIONS.md @@ -7,6 +7,7 @@ RustProxy offers built-in integrations and optional helper components. Each has | **Docker agent** | Discover running Docker services and publish generated Vhosts | Install separate agent on Docker host; use Bearer token and TLS certificate pin. [Guide](DOCKER_DISCOVERY.md). | | **Tinyauth** | Delegate authentication for selected Vhosts | Existing Nginx auth-request support remains unchanged, with fail-closed 502/504 errors. [Access](ACCESS.md). | | **Authelia** | Forward auth for selected Vhosts | Select it in Vhost Authentication, set internal service origin and trusted public HTTPS login URL, and configure the portal's per-domain cookie rules. [Access](ACCESS.md). | +| **OIDC integration method (Authelia, Pocket ID, authentik and compatible issuers)** | Central OAuth/OIDC login for protected apps across different parent domains | Select the identity provider, then OIDC. Set HTTPS issuer discovery, Client ID/Secret, explicit app callback and encrypted local session. Requires an OIDC client on the chosen provider. [Access](ACCESS.md). | | **authentik** | Forward auth for selected Vhosts, including unrelated application domains | Configure one authentik single-application proxy provider per public application and its serving outpost; enter the outpost's internal origin. RustProxy handles the reserved outpost path automatically. [Access](ACCESS.md). | | **ACME / Certbot** | Issue/renew HTTPS certificates | HTTP-01 or DNS-01 via Cloudflare, OVH or RFC2136; protect credentials. [Certificates](ACME_PROVIDERS.md). | | **Prometheus** | Scrape operational metrics | Separate endpoint/token/CIDR rules. [Monitoring](MONITORING.md). | diff --git a/docs/README.md b/docs/README.md index 6a934f4..a24ea85 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,6 @@ # RustProxy documentation -Practical guides for **RustProxy 0.34.15**. This index and the listed pages are also available in the authenticated panel at **`/docs → Reader`**. The adjacent **API** tab provides the embedded OpenAPI reference. Paths below are links to actual files in the source archive. +Practical guides for **RustProxy 0.34.16**. This index and the listed pages are also available in the authenticated panel at **`/docs → Reader`**. The adjacent **API** tab provides the embedded OpenAPI reference. Paths below are links to actual files in the source archive. ## Getting started @@ -22,7 +22,7 @@ Practical guides for **RustProxy 0.34.15**. This index and the listed pages are - [Experimental TLS post-quantum key exchange](TLS_POST_QUANTUM.md) — opt-in X25519MLKEM768, compatibility, rollback and validation. - [TCP/UDP Streams](STREAMS.md) — non-HTTP forwarding and its security boundaries. -The Vhosts and Docker discovery guides include the shared adaptive request-rate chart scale introduced in 0.34.11. Version 0.34.12 preserves that adaptive range and adds a disabled-by-default X/Y scale display option under **Settings → Appearance → Charts**. Release 0.34.13 adds global HTTP security policies and UI layout improvements, groups the global User-Agent policy under `global_security`, provides consistent section-based GET/PUT APIs, and moves manual GeoIP refresh to `POST /api/config/geoip/update`. See [Dashboard](DASHBOARD.md). Release 0.34.14 keeps the Tinyauth Nginx-header compatibility patch and dedicated fail-closed 502/504 pages. Release 0.34.15 adds optional Authelia and authentik forward-auth integrations, a shared provider runtime and a single provider-neutral HTML error template. See [Access](ACCESS.md) and [Troubleshooting](TROUBLESHOOTING.md). +The Vhosts and Docker discovery guides include the shared adaptive request-rate chart scale introduced in 0.34.11. Version 0.34.12 preserves that adaptive range and adds a disabled-by-default X/Y scale display option under **Settings → Appearance → Charts**. Release 0.34.13 adds global HTTP security policies and UI layout improvements, groups the global User-Agent policy under `global_security`, provides consistent section-based GET/PUT APIs, and moves manual GeoIP refresh to `POST /api/config/geoip/update`. See [Dashboard](DASHBOARD.md). Release 0.34.14 keeps the Tinyauth Nginx-header compatibility patch and dedicated fail-closed 502/504 pages. Release 0.34.15 adds optional Authelia and authentik forward-auth integrations, a shared provider runtime and a single provider-neutral HTML error template. Release 0.34.16 adds optional OIDC central login across unrelated domains. See [Access](ACCESS.md) and [Troubleshooting](TROUBLESHOOTING.md). ## Integrations and operations diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 7e27d57..335d08c 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -87,3 +87,11 @@ The latest source optimizations were **not all compiled or performance-tested** 6. Compare performance only under matched configurations with controlled repeatable traffic; do **not** label 0.34.0 as faster merely because its source reduces internal work. For persistent problems, use the feature-specific guide in [Documentation](README.md) and preserve a redacted error example with environment/version and request ID. + +## OIDC central login (0.34.16) + +- **Login does not start / 502:** verify that the HTTPS Issuer URL is the exact `issuer` from `/.well-known/openid-configuration`, including any path or trailing slash. RustProxy must resolve that address and connect to it with a trusted certificate, or you must configure an explicit internal backend origin for server-side OIDC requests. Discovery, authorization, token, UserInfo and JWKS endpoints must have the same HTTPS origin as the issuer. +- **Redirect URI mismatch:** register the exact `https://APP-HOST/_rustproxy/oidc/callback` value with the identity provider. The callback hostname must be explicitly listed in the protected Vhost hosts and be accessed over HTTPS. Don't protect the IdP's own login Vhost using this OIDC setting. +- **Invalid code, nonce or signature:** ensure that the provider issues an `id_token` using RS256 or ES256 and that a confidential client uses `client_secret_basic` with authorization code and PKCE S256. Repeated callback requests or overwritten browser transaction cookies are refused. A single browser login flow per protected host avoids cookie conflicts. +- **No identity on the backend:** trust only requests through RustProxy. A verified login injects `Remote-Sub`, `Remote-Issuer`, `Remote-User`, and optional profile headers; the application must explicitly consume them. An OIDC session cookie on one app/domain is not a login cookie for another. +- **User removed at the provider but session still works:** local application sessions are independent and expire after the configured `session_ttl_seconds` (one hour by default); visit `/_rustproxy/oidc/logout` on the application origin to clear the local session. RustProxy does not implement back-channel logout or token revocation. diff --git a/examples/oidc-vhost.json b/examples/oidc-vhost.json new file mode 100644 index 0000000..d789bf6 --- /dev/null +++ b/examples/oidc-vhost.json @@ -0,0 +1,22 @@ +{ + "id": "sample-oidc-app", + "frontend": { + "hosts": ["app.example.net"], + "listener_ids": ["https"] + }, + "oidc": { + "enabled": true, + "issuer_url": "https://auth.example.com", + "backend_url": "http://192.0.2.21:9091", + "client_id": "CHANGE_ME_OIDC_CLIENT_ID", + "client_secret": "CHANGE_ME_CONFIDENTIAL_OIDC_CLIENT_SECRET", + "session_secret": "CHANGE_ME_RANDOM_64_OR_MORE_CHARACTERS_SECRET", + "redirect_url": "https://app.example.net/_rustproxy/oidc/callback", + "scopes": "openid profile email", + "allow_private": true, + "session_ttl_seconds": 3600 + }, + "backends": [ + {"id": "app", "url": "http://192.0.2.20:8000", "allow_private": true} + ] +} diff --git a/langs/en_US.json b/langs/en_US.json index cb723db..473e3cf 100644 --- a/langs/en_US.json +++ b/langs/en_US.json @@ -857,11 +857,26 @@ "aclNetwork": "IP/CIDR or DNS name", "aclAction": "Action", "aclAdd": "Add IP rule", + "authProvider": "Identity provider", + "authMethod": "Integration method", + "authForwardAuth": "Forward Auth", + "authPocketId": "Pocket ID", + "authCustomOidc": "Other OIDC provider", "authMode": "Authentication", "authNone": "None", "authTinyauth": "Tinyauth", "authAuthelia": "Authelia", "authAuthentik": "authentik", + "authOidc": "OIDC (central login)", + "oidcIssuer": "OIDC issuer (HTTPS)", + "oidcBackend": "OIDC internal service URL (optional)", + "oidcRedirect": "Application callback URL (HTTPS)", + "oidcClient": "Client ID", + "oidcSecret": "Client Secret", + "oidcSession": "RustProxy session secret (at least 32 characters)", + "oidcScopes": "OIDC scopes", + "oidcTtl": "Session lifetime (seconds)", + "oidcHelp": "Cross-domain central login using OIDC Authorization Code + PKCE. Register the exact callback URL with your provider. Requires trusted HTTPS, a session secret and a confidential client (client_secret_basic). Supported ID token signatures: RS256/ES256. Discovery endpoints must use the issuer origin. Application sessions are separate; RustProxy injects verified Remote-User, Remote-Sub and optional attributes.", "forwardAuthUrl": "Forward-auth service URL (internal)", "forwardAuthLogin": "Authelia public login URL (HTTPS)", "forwardAuthAdvanced": "Advanced provider settings", @@ -1503,7 +1518,7 @@ "vhBackendHeader": "Expose selected backend (X-RustProxy-Backend)", "vhBackendTimingHeader": "Expose backend response time (X-RustProxy-Backend-Time)", "vhBackendDiagnosticsHelp": "Opt-in response headers. Backend time measures origin request-to-response-headers latency; responses served without contacting an origin do not report a backend.", - "authModeHelp": "Choose one authentication provider. The IP access list below is independent and can be combined with either provider.", + "authModeHelp": "Select an identity provider and a supported integration method. Forward Auth checks sessions via the provider; OIDC uses central login with a callback on the protected application. Existing Vhost integrations remain supported.", "taAdvanced": "Advanced Tinyauth settings", "notfound": "404 hosts", "notfoundTitle": "404 hosts, under control.", diff --git a/langs/pl_pl.json b/langs/pl_pl.json index d13d7e8..cc6d2a5 100644 --- a/langs/pl_pl.json +++ b/langs/pl_pl.json @@ -857,11 +857,26 @@ "aclNetwork": "Adres IP/CIDR lub nazwa DNS", "aclAction": "Akcja", "aclAdd": "Dodaj regułę IP", + "authProvider": "Dostawca tożsamości", + "authMethod": "Metoda integracji", + "authForwardAuth": "Forward Auth", + "authPocketId": "Pocket ID", + "authCustomOidc": "Inny dostawca OIDC", "authMode": "Uwierzytelnianie", "authNone": "Brak", "authTinyauth": "Tinyauth", "authAuthelia": "Authelia", "authAuthentik": "authentik", + "authOidc": "OIDC (logowanie centralne)", + "oidcIssuer": "Wystawca OIDC (Issuer HTTPS)", + "oidcBackend": "Adres wewnętrzny usługi OIDC (opcjonalny)", + "oidcRedirect": "Adres callbacku na domenie aplikacji (HTTPS)", + "oidcClient": "Client ID", + "oidcSecret": "Client Secret", + "oidcSession": "Sekret sesji RustProxy (min. 32 znaki)", + "oidcScopes": "Zakresy OIDC (scopes)", + "oidcTtl": "Czas sesji (sekundy)", + "oidcHelp": "Centralne logowanie między domenami przez OIDC Authorization Code + PKCE. Zarejestruj dokładnie ten adres callbacku u dostawcy. Wymaga zaufanego HTTPS, sekretu sesji i klienta poufnego (client_secret_basic). Obsługiwane podpisy ID tokenu: RS256/ES256. Wszystkie adresy discovery muszą używać originu Issuer. Sesja aplikacji jest osobna; RustProxy przekazuje zweryfikowane Remote-User, Remote-Sub i opcjonalne atrybuty.", "forwardAuthUrl": "Adres usługi autoryzacji (wewnętrzny)", "forwardAuthLogin": "Publiczny adres logowania Authelia (HTTPS)", "forwardAuthAdvanced": "Zaawansowane ustawienia dostawcy", @@ -1503,7 +1518,7 @@ "vhBackendHeader": "Udostępniaj wybrany backend (X-RustProxy-Backend)", "vhBackendTimingHeader": "Udostępniaj czas odpowiedzi backendu (X-RustProxy-Backend-Time)", "vhBackendDiagnosticsHelp": "Opcjonalne nagłówki odpowiedzi. Czas backendu oznacza czas od wysłania żądania do otrzymania nagłówków odpowiedzi; odpowiedzi obsłużone bez kontaktu z originem nie raportują backendu.", - "authModeHelp": "Wybierz jedną metodę uwierzytelniania. Lista dostępu IP poniżej działa niezależnie i może być używana razem z dowolną metodą.", + "authModeHelp": "Wybierz dostawcę tożsamości i obsługiwaną metodę integracji. Forward Auth sprawdza sesję u dostawcy, a OIDC zapewnia logowanie centralne z callbackiem na domenie chronionej aplikacji. Stare konfiguracje Vhostów pozostają obsługiwane.", "taAdvanced": "Zaawansowane ustawienia Tinyauth", "notfound": "Hosty 404", "notfoundTitle": "Hosty 404 pod kontrolą.", diff --git a/src/admin/openapi.json b/src/admin/openapi.json index 38c6adf..0b7d6cc 100644 --- a/src/admin/openapi.json +++ b/src/admin/openapi.json @@ -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": { diff --git a/src/admin/web/vhosts/editor.js b/src/admin/web/vhosts/editor.js index bb1b8e5..838177e 100644 --- a/src/admin/web/vhosts/editor.js +++ b/src/admin/web/vhosts/editor.js @@ -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") }, diff --git a/src/admin/web/vhosts/render.js b/src/admin/web/vhosts/render.js index 8aecf36..450a76d 100644 --- a/src/admin/web/vhosts/render.js +++ b/src/admin/web/vhosts/render.js @@ -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")); diff --git a/src/auth_provider/choice.rs b/src/auth_provider/choice.rs new file mode 100644 index 0000000..77ff708 --- /dev/null +++ b/src/auth_provider/choice.rs @@ -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::(json).unwrap(), selection); + assert!(serde_json::from_value::( + serde_json::json!({"provider":"pocket_id","method":"forward_auth"}) + ) + .unwrap() + .validate() + .is_err()); + assert!(serde_json::from_value::( + serde_json::json!({"provider":"unknown","method":"oidc"}) + ) + .is_err()); + } +} diff --git a/src/auth_provider/mod.rs b/src/auth_provider/mod.rs index 8a63e1f..0b875e6 100644 --- a/src/auth_provider/mod.rs +++ b/src/auth_provider/mod.rs @@ -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> + Send + 'a>>; pub type Route<'a> = Pin + 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, 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 diff --git a/src/config/routing.rs b/src/config/routing.rs index 43ccbc9..78fcdaf 100644 --- a/src/config/routing.rs +++ b/src/config/routing.rs @@ -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(), diff --git a/src/config/tests.rs b/src/config/tests.rs index ad227fd..734ac55 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -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(); diff --git a/src/lib.rs b/src/lib.rs index f09c9d3..3ed57b2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/oidc/mod.rs b/src/oidc/mod.rs new file mode 100644 index 0000000..9fe06e8 --- /dev/null +++ b/src/oidc/mod.rs @@ -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, + pub tls_ca_pem: Option, + 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 { + 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> { + 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::>() + .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, + 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, + upstream_origin: Url, + host_header: HeaderValue, + internal_override: bool, + cache: Mutex>, + 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, + name: Option, + groups: Vec, + expires: u64, +} +fn now() -> Result { + Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs()) +} +fn random_b64(bytes: usize) -> Result { + 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 { + 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(secret: &str, value: &T) -> Result { + 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(secret: &str, encoded: &str) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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::(&data)?)?; + } + Ok(claims) + } + async fn callback( + &self, + request: Request, + 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::(&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, + context: &'a forwarding::Context, + shutdown: &'a CancellationToken, + ) -> Option> { + 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"); diff --git a/src/oidc/tests.rs b/src/oidc/tests.rs new file mode 100644 index 0000000..de3df65 --- /dev/null +++ b/src/oidc/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::("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::(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::(&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()); + } +} diff --git a/src/oidc/token.rs b/src/oidc/token.rs new file mode 100644 index 0000000..7edd866 --- /dev/null +++ b/src/oidc/token.rs @@ -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, + pub name: Option, + pub groups: Vec, +} + +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> { + 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 { + 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::>()?, + _ => 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(()) +} diff --git a/src/proxy/cache_plan/tests.rs b/src/proxy/cache_plan/tests.rs index dc8cad2..cc6c691 100644 --- a/src/proxy/cache_plan/tests.rs +++ b/src/proxy/cache_plan/tests.rs @@ -28,6 +28,7 @@ fn disabled_cache_retains_reason_without_matching_force_rules_or_reparsing_heade "tinyauth", "authelia", "authentik", + "oidc", "sticky_cookie", "request_upgrade", ] { diff --git a/src/vhost/model.rs b/src/vhost/model.rs index c7626be..186d70a 100644 --- a/src/vhost/model.rs +++ b/src/vhost/model.rs @@ -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, + #[serde(default)] pub certificate_id: Option, #[serde(default)] pub allow_certificate_mismatch: bool, diff --git a/src/vhost/validation.rs b/src/vhost/validation.rs index 53ab563..0da6834 100644 --- a/src/vhost/validation.rs +++ b/src/vhost/validation.rs @@ -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())?;