This commit is contained in:
Mateusz Gruszczyński committed 2026-10-27 13:49:21 +01:00
1 parent f27fbf6a30
commit 2c03fcf248
18 files changed
+430 -920

No files matched your search

+1
View File
@@ -4,6 +4,7 @@ Release history focused on **features, settings, default behavior, compatibility
## 0.34.16 — optional OIDC central login (source candidate) ## 0.34.16 — optional OIDC central login (source candidate)
- **Configuration size fix:** increased the total serialized configuration limit from 256 KiB to 2 MiB. The administration request limit, configuration file import, SQLite/history loading and NPM migration utility use the same expanded bound; the existing limits on Vhosts, backends and locations remain unchanged.
- **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. - **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. - **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. - **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.
+10 -10
View File
@@ -2,7 +2,7 @@
4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example 4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example
9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore 9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore
92250158a2241c61d4c61aa840025fd5dce9aec3d406d7f199a8673c7ff0cebb AGENTS.md 92250158a2241c61d4c61aa840025fd5dce9aec3d406d7f199a8673c7ff0cebb AGENTS.md
d5655bd4146fda0ce345a8e7cf011f662275728d3d48e41561be6a7c4b7a454d CHANGELOG.md 8a7f5b8690547b6f2aeeeed884ee7025d5201bb03695b04813b3ecdc80d47103 CHANGELOG.md
3deb7f5702d36446f069cc9c5927bdcbfdc306887bec6f45fbc9507295b6c5c9 Cargo.lock 3deb7f5702d36446f069cc9c5927bdcbfdc306887bec6f45fbc9507295b6c5c9 Cargo.lock
024fe1c84a50255b7b32ea12110285c60b6386daba02a57843324baee5ae94fb Cargo.toml 024fe1c84a50255b7b32ea12110285c60b6386daba02a57843324baee5ae94fb Cargo.toml
25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile 25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile
@@ -29,11 +29,11 @@ e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/n
5220d669171ec3ad65f50e0bc8de5178dadaa4904def8e1a4f61002aa9e9a55b docker-agent/server.go 5220d669171ec3ad65f50e0bc8de5178dadaa4904def8e1a4f61002aa9e9a55b docker-agent/server.go
26af69f0dca733c7659bfdb7c9e07f2521aaaa83913735d952a33d1bda85b95c docs/ACCESS.md 26af69f0dca733c7659bfdb7c9e07f2521aaaa83913735d952a33d1bda85b95c docs/ACCESS.md
147755fb33e9e91098a4d2dfbe6df8b0d92d602e002d568e64a0f815641be2c2 docs/ACME_PROVIDERS.md 147755fb33e9e91098a4d2dfbe6df8b0d92d602e002d568e64a0f815641be2c2 docs/ACME_PROVIDERS.md
b359ca6b82fcb74f2ebdc59a68270d915c781586ea100e9598d4989815f21b2d docs/API.md a7e307a2d6bff1ee3d6fbdef14e873d95acb691ac8f4730cebab8f25335f6425 docs/API.md
2362ab34020d4e0dcd70e280e9539ef7764b63212856ee08a3a55906c48f6421 docs/API_ACCESS.md 2362ab34020d4e0dcd70e280e9539ef7764b63212856ee08a3a55906c48f6421 docs/API_ACCESS.md
728b15b9825f547f34c4121219784798dd046fbbdd72ece8906d0cd77e8efc64 docs/BACKUP.md 728b15b9825f547f34c4121219784798dd046fbbdd72ece8906d0cd77e8efc64 docs/BACKUP.md
3219aae15fd2e10157343ecdad8b175bd9735f39bccb31a5cb811553c13084dd docs/CACHE.md 3219aae15fd2e10157343ecdad8b175bd9735f39bccb31a5cb811553c13084dd docs/CACHE.md
b29bceb308327db9a45faba3f144c2fd7e057c833d5cafdcc216cf82aedd7854 docs/CONFIGURATION.md 3cdf36e6345f4bbbae846d975fbdbe7a87afea93563d24c06e3733eae6809417 docs/CONFIGURATION.md
80bcc666736b10f6202386d0342e8d6feeba858d77aea310bf3ec6de01c2bfb3 docs/CONTAINER.md 80bcc666736b10f6202386d0342e8d6feeba858d77aea310bf3ec6de01c2bfb3 docs/CONTAINER.md
dc670d912a6a946160c2f8528c9bb710ec38ebe406dcb7dcaf18fb2653f667b6 docs/CUSTOM_LOCATIONS.md dc670d912a6a946160c2f8528c9bb710ec38ebe406dcb7dcaf18fb2653f667b6 docs/CUSTOM_LOCATIONS.md
e350c5081085b79e579d1500fa07feee94a709adaf6dfde7b3d9af124a78d184 docs/DASHBOARD.md e350c5081085b79e579d1500fa07feee94a709adaf6dfde7b3d9af124a78d184 docs/DASHBOARD.md
@@ -110,7 +110,7 @@ e4d15aa6aa864d04b9b30aca33826b833632af02ca643f8b23e219a5ea1298dd scripts/deploy
a641bd35400c56f3b68b0a92e97b602d06090f03972ca8f01fa744e90dd97403 scripts/deploy/upgrade.py a641bd35400c56f3b68b0a92e97b602d06090f03972ca8f01fa744e90dd97403 scripts/deploy/upgrade.py
2cc658faaa610b21b48942c59b955525539ad4503fd60f2a49c7e626a22faaff scripts/migration/migrate_npm.py 2cc658faaa610b21b48942c59b955525539ad4503fd60f2a49c7e626a22faaff scripts/migration/migrate_npm.py
a459d95bbb0076f33ade8b273b813308167f9d8d45242b309a2d18320ca0093c scripts/migration/migrate_npm_api.py a459d95bbb0076f33ade8b273b813308167f9d8d45242b309a2d18320ca0093c scripts/migration/migrate_npm_api.py
31ccad3ec3c880119f3b7002f0c6652f8b43634edad8ab3763cb0e4497d0bb1f scripts/migration/migrate_npm_core.py 4c905a387f210c30fbf12a7738676fd39819bc96bc4a272da1db5f18a2d3013e scripts/migration/migrate_npm_core.py
f040ab366fb138a48debf764c71538c2471235353e2426316264c5d3c2d59762 scripts/preflight.py f040ab366fb138a48debf764c71538c2471235353e2426316264c5d3c2d59762 scripts/preflight.py
3d6a0503afc25beab79ddc86864bcbb15248ded7368d33aa64321b8cdff59933 scripts/resolve_dependencies_0312.sh 3d6a0503afc25beab79ddc86864bcbb15248ded7368d33aa64321b8cdff59933 scripts/resolve_dependencies_0312.sh
20124173452b4514bad8850c1637569a9a191882478451e549bd16b432605fc1 scripts/runtime/acme_provider.py 20124173452b4514bad8850c1637569a9a191882478451e549bd16b432605fc1 scripts/runtime/acme_provider.py
@@ -160,13 +160,13 @@ b3c3c2d33c150eb42ce210c6074e5eff6a8af9391118012e413bc9323d3ae50c src/admin/pref
62ecbae5dee00aeeca3eb806e2605372471dfab971cde8f66f11e711df8fcea1 src/admin/public/mod.rs 62ecbae5dee00aeeca3eb806e2605372471dfab971cde8f66f11e711df8fcea1 src/admin/public/mod.rs
7a00e4ab943f74d2b1bdcbfab7f9cd3f584e9f59536b4aca6c4c31f5f5508d12 src/admin/public/tests.rs 7a00e4ab943f74d2b1bdcbfab7f9cd3f584e9f59536b4aca6c4c31f5f5508d12 src/admin/public/tests.rs
6930b4f4b45257772b321b20a9becdf9bc4610274852948ba38c2d0cdf02dfaf src/admin/refresh_preferences.rs 6930b4f4b45257772b321b20a9becdf9bc4610274852948ba38c2d0cdf02dfaf src/admin/refresh_preferences.rs
cc3aa1b094b0d869d141754ac2877817ec40a362acfd7fc3167ea3c9f1ff1a18 src/admin/router.rs c8454f3092145c5fefdc8d93951ae46f192a6eb8139dbe288284b3a1c803b192 src/admin/router.rs
e135431cb078ddb483165581123c64af8f00ea67acb3a395ec3e53c85d0d0d45 src/admin/session.rs e135431cb078ddb483165581123c64af8f00ea67acb3a395ec3e53c85d0d0d45 src/admin/session.rs
7aeb784a3ef58795c8fe26467bb4e1a00e61420911f798a584ab09c12f73a754 src/admin/status.rs 7aeb784a3ef58795c8fe26467bb4e1a00e61420911f798a584ab09c12f73a754 src/admin/status.rs
2d5b4c28fd0bd48b06890fa8ec44a2296de948c1f88eb2909f4b495710d4e13e src/admin/status_live.rs 2d5b4c28fd0bd48b06890fa8ec44a2296de948c1f88eb2909f4b495710d4e13e src/admin/status_live.rs
8315c856cd50f1072593ecb30c274d42d82f078b5fb93e7c68a8857cd81b0841 src/admin/status_system.rs 8315c856cd50f1072593ecb30c274d42d82f078b5fb93e7c68a8857cd81b0841 src/admin/status_system.rs
cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs
d5af049839db73e4d44f2b4455d072c6fb167ac2bd87b3f4aab0ef1d1a2e1209 src/admin/tests.rs 1d3ea6803763827251317070329c8eaf86491d4a6adb8a90406f32009e4a9b1c src/admin/tests.rs
c18c369124368f58524652565102817ed69fd25b222b0c5e737ef7632b45dcfb src/admin/web/acl-picker.js c18c369124368f58524652565102817ed69fd25b222b0c5e737ef7632b45dcfb src/admin/web/acl-picker.js
48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js 48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js
86abf50f39862d821cd2e43ca425671aa85289272915132c5a9a5dd1fc4182e2 src/admin/web/api-tokens.js 86abf50f39862d821cd2e43ca425671aa85289272915132c5a9a5dd1fc4182e2 src/admin/web/api-tokens.js
@@ -323,13 +323,13 @@ d87f99c48c05ededb0586626d3179391257f54363516119f1567fa0590546485 src/cache/varn
8de82d9188c9c337690c6ec4afdad9fff20651c358e34602a93d8bb8c3d2489a src/compression/tests.rs 8de82d9188c9c337690c6ec4afdad9fff20651c358e34602a93d8bb8c3d2489a src/compression/tests.rs
e695079333ebdc2a6e7b3337cfabef171b3a0a01da0597b77feff70d417f833e src/config.rs e695079333ebdc2a6e7b3337cfabef171b3a0a01da0597b77feff70d417f833e src/config.rs
9f6272bd6d1f403cf6324b12472fd5c7d911a387139b57105ba1819e48ea0a54 src/config/backend.rs 9f6272bd6d1f403cf6324b12472fd5c7d911a387139b57105ba1819e48ea0a54 src/config/backend.rs
f4df024acdb24be450822e58108af23db3dc82bf1f77531cd16fe340fc01062c src/config/compile.rs 53409e9f92815cef254e138213b32f8d05a01ebc62e176064dc7463c86ce2d71 src/config/compile.rs
fbea01fea1213d98aea812c1b78d5db289e09200b98ae514d742235cd3fa70e1 src/config/internal_routing.rs fbea01fea1213d98aea812c1b78d5db289e09200b98ae514d742235cd3fa70e1 src/config/internal_routing.rs
101253b4c9275e38dc7e1088051d6cd55ae7616213eb1c02e3e1c5ffc6fea4a0 src/config/model.rs c21961205a7f58ca25dea553ce56590e3d8f4011bb657577421e773e130e5527 src/config/model.rs
75839458ddbc7480ec4492a95eeb67b617ba7d62f7b67e7a767a3453b74dbcbc src/config/route_policy_tests.rs 75839458ddbc7480ec4492a95eeb67b617ba7d62f7b67e7a767a3453b74dbcbc src/config/route_policy_tests.rs
3c0ede1417e83720b010efa670fe8494e3ad0e9138065d725cbb9c969de6c559 src/config/routing.rs 3c0ede1417e83720b010efa670fe8494e3ad0e9138065d725cbb9c969de6c559 src/config/routing.rs
dd9a6ad8f58e0cdd1ae74f3f972fee0ac14383cd8b8f518403b9a36c169f35bd src/config/snapshot.rs dd9a6ad8f58e0cdd1ae74f3f972fee0ac14383cd8b8f518403b9a36c169f35bd src/config/snapshot.rs
9d2fdb3f4c9d4117a6766e56ebe6e1e34687d40100728de98f47e9fce3fe3aa5 src/config/tests.rs 8676b307dfcca462d9bdd934e3f1432b8b903f89542d502ae20f8a7b24742557 src/config/tests.rs
f35de0ee1516ece6bbfd2b2a7db7d1e0f5048b0ec47cf2a3765dffb799abfd93 src/config/transport_tests.rs f35de0ee1516ece6bbfd2b2a7db7d1e0f5048b0ec47cf2a3765dffb799abfd93 src/config/transport_tests.rs
e0f46d885ed81ce306a110560cdd2c217dadde054986d89fcec835ebf6939d65 src/db.rs e0f46d885ed81ce306a110560cdd2c217dadde054986d89fcec835ebf6939d65 src/db.rs
c290d0cb02dfa5d2c60ecabac1cfb20c8884e3d14cca1944906c6d8e677f1784 src/db/api_tokens.rs c290d0cb02dfa5d2c60ecabac1cfb20c8884e3d14cca1944906c6d8e677f1784 src/db/api_tokens.rs
@@ -377,7 +377,7 @@ a9e453c94fc37e37d9f1f461494fffd5f88e13e15f6cd4158464dee4fafb14bd src/logging/te
5a98d0e249338e73aa57ae2e352dc16db2f79c2d042c1544ce1c2022da76a31c src/main/proxy_runtime.rs 5a98d0e249338e73aa57ae2e352dc16db2f79c2d042c1544ce1c2022da76a31c src/main/proxy_runtime.rs
b240033715323e7cb7b9bcaf92edd481f7089b4da6bab25005ca6733da8756dc src/main/shutdown/mod.rs b240033715323e7cb7b9bcaf92edd481f7089b4da6bab25005ca6733da8756dc src/main/shutdown/mod.rs
0a6578a0f4023b9f8d73043bf942dc5e096ef9afb8c0d2f131c14f65d08ac01d src/main/shutdown/tests.rs 0a6578a0f4023b9f8d73043bf942dc5e096ef9afb8c0d2f131c14f65d08ac01d src/main/shutdown/tests.rs
ca01c97cc52a50797306193a546e4379fe3ce9401b0c936c756b881180315ab0 src/main/tasks.rs ff491043180085800b786b77e6bd3c400b60051012482918440cd5dc6493d1e7 src/main/tasks.rs
a719c51aebcba0f16f8810c37618201954e79d6806da085ee6c5e4697a05b521 src/metrics.rs a719c51aebcba0f16f8810c37618201954e79d6806da085ee6c5e4697a05b521 src/metrics.rs
9c7ed78345abc33d260f8d5cd52339bf8de748c08b53eaa2bdea8a32a0ea2a20 src/metrics/global.rs 9c7ed78345abc33d260f8d5cd52339bf8de748c08b53eaa2bdea8a32a0ea2a20 src/metrics/global.rs
31154052757c2bb975b8ce8cad67f3fad7b5132b615a73d5b0d7bf1b5311722b src/metrics/rate_sampling.rs 31154052757c2bb975b8ce8cad67f3fad7b5132b615a73d5b0d7bf1b5311722b src/metrics/rate_sampling.rs
+1
View File
@@ -41,6 +41,7 @@ Certificate inspection accepts `{ "cert_file": "filename.pem" }` for a managed f
- Ordinary API calls require an administrator browser/session cookie on the admin listener, or a scoped general API Bearer token for explicitly allowlisted API methods/paths. Browser-session writes require the matching Origin, `X-CSRF-Token` and `X-RustProxy-Request: 1`, in addition to Host validation. Scoped Bearer-token requests are exempt from browser CSRF requirements; the server enforces the token permission, source IP allowlist and HTTPS for non-loopback access instead. - Ordinary API calls require an administrator browser/session cookie on the admin listener, or a scoped general API Bearer token for explicitly allowlisted API methods/paths. Browser-session writes require the matching Origin, `X-CSRF-Token` and `X-RustProxy-Request: 1`, in addition to Host validation. Scoped Bearer-token requests are exempt from browser CSRF requirements; the server enforces the token permission, source IP allowlist and HTTPS for non-loopback access instead.
- The Swagger **API** tab uses the signed-in session. Read calls can be executed; mutations require explicit confirmation. It cannot bypass server-side authorization. - The Swagger **API** tab uses the signed-in session. Read calls can be executed; mutations require explicit confirmation. It cannot bypass server-side authorization.
- The serialized configuration limit is 2 MiB across all sections; JSON request bodies allow a bounded 64 KiB formatting overhead. Saves over the limit are rejected without applying partial changes.
- Configuration writes are revisioned (`expected_revision`) and rejected if based on a stale save. `GET /api/config` returns `{revision, config, storage}`; `storage` is runtime metadata and must not be copied inside the editable `config` object or included as a write parameter. PUT and history-restore responses remain `{revision, config}`. Prefer section updates over replacing the entire document when practical. - Configuration writes are revisioned (`expected_revision`) and rejected if based on a stale save. `GET /api/config` returns `{revision, config, storage}`; `storage` is runtime metadata and must not be copied inside the editable `config` object or included as a write parameter. PUT and history-restore responses remain `{revision, config}`. Prefer section updates over replacing the entire document when practical.
- Each supported section has the same **GET** and **PUT** value shape. `GET /api/config/cache` returns `{revision, section: "cache", value: {…}}`. `PUT /api/config/cache` accepts `{"expected_revision": 12, "value": {…}}`, using the **entire** section value from a fresh GET after modification. The response is the full updated `{revision, config}` envelope (not a section view). Even changes to different sections must use the latest global revision: concurrent stale writes return 409. This is replacement, not JSON Merge Patch. - Each supported section has the same **GET** and **PUT** value shape. `GET /api/config/cache` returns `{revision, section: "cache", value: {…}}`. `PUT /api/config/cache` accepts `{"expected_revision": 12, "value": {…}}`, using the **entire** section value from a fresh GET after modification. The response is the full updated `{revision, config}` envelope (not a section view). Even changes to different sections must use the latest global revision: concurrent stale writes return 409. This is replacement, not JSON Merge Patch.
- Section boundaries: `logging` uses `{"logging": {…}, "error_log": {…}}`; `response` uses `{"server_header": {…}, "diagnostic_headers": {…}, "compression": {…}}`; `global-security` uses a **single** `GlobalSecurity` object including `user_agent_blocking`. For this section, omitting `user_agent_blocking` is an invalid PUT rather than silently clearing the denylist. Independent `user-agent-blocking` and `error_log` section endpoints do not exist. The prior logging-only `PUT /api/config/logging` payload is no longer accepted: read the full logging section and send the paired value. - Section boundaries: `logging` uses `{"logging": {…}, "error_log": {…}}`; `response` uses `{"server_header": {…}, "diagnostic_headers": {…}, "compression": {…}}`; `global-security` uses a **single** `GlobalSecurity` object including `user_agent_blocking`. For this section, omitting `user_agent_blocking` is an invalid PUT rather than silently clearing the denylist. Independent `user-agent-blocking` and `error_log` section endpoints do not exist. The prior logging-only `PUT /api/config/logging` payload is no longer accepted: read the full logging section and send the paired value.
+2
View File
@@ -6,6 +6,8 @@ Most settings are managed in the authenticated panel. Save changes after validat
## Configuration storage: SQLite or JSON file ## Configuration storage: SQLite or JSON file
The serialized configuration has a 2 MiB limit across all sections, including Vhosts. The administration API accepts compact configuration updates and bounded JSON formatting overhead; individual Vhost and backend limits still apply.
**Settings → Manual config** shows the active storage mode and path, sourced from the read-only `storage` member of the authenticated `GET /api/config` response (`revision`, `config`, `storage`). With no `--config`, the authoritative configuration is in `<data-dir>/config.sqlite3` (normally `/var/lib/rustproxy/config.sqlite3`). With `--config`, that JSON file is authoritative, while SQLite still stores revision history, accounts and audit records. Switching modes requires a service restart; the panel does not change the startup argument. **Settings → Manual config** shows the active storage mode and path, sourced from the read-only `storage` member of the authenticated `GET /api/config` response (`revision`, `config`, `storage`). With no `--config`, the authoritative configuration is in `<data-dir>/config.sqlite3` (normally `/var/lib/rustproxy/config.sqlite3`). With `--config`, that JSON file is authoritative, while SQLite still stores revision history, accounts and audit records. Switching modes requires a service restart; the panel does not change the startup argument.
### Optional JSON configuration source ### Optional JSON configuration source
+1 -1
View File
@@ -29,7 +29,7 @@ import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
MAX_CONFIG_BYTES = 262_144 MAX_CONFIG_BYTES = 2_097_152
class MigrationError(RuntimeError): class MigrationError(RuntimeError):
+1 -1
View File
@@ -285,7 +285,7 @@ pub fn router(state: Arc<App>) -> Router {
.route("/api/metrics/summary", get(metrics_summary)) .route("/api/metrics/summary", get(metrics_summary))
.fallback(|| async { reject(StatusCode::NOT_FOUND, "not_found") }) .fallback(|| async { reject(StatusCode::NOT_FOUND, "not_found") })
.layer(DefaultBodyLimit::max( .layer(DefaultBodyLimit::max(
crate::config::MAX_CONFIG_BYTES + 1024, crate::config::MAX_CONFIG_BYTES + 65_536,
)) ))
.layer(middleware::from_fn_with_state(state.clone(), guard)) .layer(middleware::from_fn_with_state(state.clone(), guard))
.layer(middleware::from_fn_with_state(state.clone(), observe_admin)) .layer(middleware::from_fn_with_state(state.clone(), observe_admin))
+44
View File
@@ -832,6 +832,50 @@ mod tests {
store.close().await; store.close().await;
} }
#[tokio::test]
async fn vhosts_section_accepts_large_config_and_persists_it() {
let config = config::demo_config("127.0.0.1:9000".parse().unwrap());
let (router, store, _dir, secret, csrf) = fixture_with_config(config.clone()).await;
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
let mut vhosts = config.vhosts;
vhosts[0].routes = (0..5)
.map(|index| crate::vhost::PathRoute {
id: format!("large-{index}"),
path_prefix: format!("/large-{index}"),
response: Some(crate::vhost::LocationResponse {
body: "x".repeat(60_000),
..Default::default()
}),
..Default::default()
})
.collect();
let body = serde_json::to_vec(&json!({"expected_revision": 0, "value": vhosts})).unwrap();
assert!(body.len() > 262_144);
let response = router
.clone()
.oneshot(
request("/api/config/vhosts")
.method("PUT")
.header("content-type", "application/json")
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(json_body(response).await["revision"], 1);
let saved = store.load().await.unwrap();
assert_eq!(saved.revision, 1);
assert_eq!(saved.config.vhosts[0].routes.len(), 5);
let history = store.configuration_revision(1).await.unwrap().unwrap();
assert_eq!(history.config.vhosts[0].routes.len(), 5);
store.close().await;
}
#[tokio::test] #[tokio::test]
async fn section_updates_are_revisioned_and_do_not_require_full_config_payloads() { async fn section_updates_are_revisioned_and_do_not_require_full_config_payloads() {
let (router, store, _dir, secret, csrf) = fixture().await; let (router, store, _dir, secret, csrf) = fixture().await;
+6 -27
View File
@@ -58,10 +58,7 @@ pub struct Choice {
} }
impl Choice { impl Choice {
pub fn validate(self) -> Result<()> { pub fn validate(self) -> Result<()> {
ensure!( ensure!(self.provider.supports(self.method), "unsupported authentication provider and integration method combination");
self.provider.supports(self.method),
"unsupported authentication provider and integration method combination"
);
Ok(()) Ok(())
} }
} }
@@ -72,11 +69,7 @@ mod tests {
#[test] #[test]
fn supported_methods_are_explicit_and_reject_invalid_pairs() { fn supported_methods_are_explicit_and_reject_invalid_pairs() {
for provider in [ for provider in [IdentityProvider::Tinyauth, IdentityProvider::Authelia, IdentityProvider::Authentik] {
IdentityProvider::Tinyauth,
IdentityProvider::Authelia,
IdentityProvider::Authentik,
] {
assert!(provider.supports(IntegrationMethod::ForwardAuth)); assert!(provider.supports(IntegrationMethod::ForwardAuth));
assert!(provider.supports(IntegrationMethod::Oidc)); assert!(provider.supports(IntegrationMethod::Oidc));
} }
@@ -91,25 +84,11 @@ mod tests {
#[test] #[test]
fn selection_serializes_as_independent_provider_and_method() { fn selection_serializes_as_independent_provider_and_method() {
let selection = Choice { let selection = Choice { provider: IdentityProvider::Authelia, method: IntegrationMethod::Oidc };
provider: IdentityProvider::Authelia,
method: IntegrationMethod::Oidc,
};
let json = serde_json::to_value(selection).unwrap(); let json = serde_json::to_value(selection).unwrap();
assert_eq!( assert_eq!(json, serde_json::json!({"provider":"authelia","method":"oidc"}));
json,
serde_json::json!({"provider":"authelia","method":"oidc"})
);
assert_eq!(serde_json::from_value::<Choice>(json).unwrap(), selection); assert_eq!(serde_json::from_value::<Choice>(json).unwrap(), selection);
assert!(serde_json::from_value::<Choice>( assert!(serde_json::from_value::<Choice>(serde_json::json!({"provider":"pocket_id","method":"forward_auth"})).unwrap().validate().is_err());
serde_json::json!({"provider":"pocket_id","method":"forward_auth"}) assert!(serde_json::from_value::<Choice>(serde_json::json!({"provider":"unknown","method":"oidc"})).is_err());
)
.unwrap()
.validate()
.is_err());
assert!(serde_json::from_value::<Choice>(
serde_json::json!({"provider":"unknown","method":"oidc"})
)
.is_err());
} }
} }
+30 -76
View File
@@ -130,9 +130,7 @@ impl Selection<'_> {
if let Some(choice) = self.choice { if let Some(choice) = self.choice {
choice.validate()?; choice.validate()?;
let matches_configuration = match choice.method { let matches_configuration = match choice.method {
IntegrationMethod::Basic => { IntegrationMethod::Basic => self.basic_enabled && choice.provider == IdentityProvider::Basic,
self.basic_enabled && choice.provider == IdentityProvider::Basic
}
IntegrationMethod::ForwardAuth => match choice.provider { IntegrationMethod::ForwardAuth => match choice.provider {
IdentityProvider::Tinyauth => self.tinyauth.enabled, IdentityProvider::Tinyauth => self.tinyauth.enabled,
IdentityProvider::Authelia => self.authelia.enabled, IdentityProvider::Authelia => self.authelia.enabled,
@@ -141,10 +139,7 @@ impl Selection<'_> {
}, },
IntegrationMethod::Oidc => self.oidc.enabled, IntegrationMethod::Oidc => self.oidc.enabled,
}; };
ensure!( ensure!(matches_configuration, "authentication selection does not match the enabled settings");
matches_configuration,
"authentication selection does not match the enabled settings"
);
} }
Ok(()) Ok(())
} }
@@ -164,12 +159,9 @@ impl Selection<'_> {
return Ok(self.authentik.compile(policy, bindings)?.map(Compiled::new)); return Ok(self.authentik.compile(policy, bindings)?.map(Compiled::new));
} }
if self.oidc.enabled { if self.oidc.enabled {
let (id, label) = self.choice.map_or(("oidc", "OIDC"), |choice| { let (id, label) = self.choice.map_or(("oidc", "OIDC"),
(choice.provider.id(), choice.provider.display_name()) |choice| (choice.provider.id(), choice.provider.display_name()));
}); return Ok(self.oidc.compile(policy, bindings)?
return Ok(self
.oidc
.compile(policy, bindings)?
.map(|provider| Compiled::new(provider.with_provider(id, label)))); .map(|provider| Compiled::new(provider.with_provider(id, label))));
} }
Ok(None) Ok(None)
@@ -258,69 +250,47 @@ mod tests {
tinyauth.url = "http://127.0.0.1:3000".into(); tinyauth.url = "http://127.0.0.1:3000".into();
tinyauth.login_url = "https://auth.example.net".into(); tinyauth.login_url = "https://auth.example.net".into();
let oidc = oidc::Settings { let oidc = oidc::Settings {
enabled: true, enabled: true, issuer_url: "https://auth.gruszczynski.cc".into(),
issuer_url: "https://auth.test.com".into(),
backend_url: "http://127.0.0.1:9091".into(), backend_url: "http://127.0.0.1:9091".into(),
client_id: "test-client".into(), client_id: "test-client".into(), client_secret: "test-secret".into(),
client_secret: "test-secret".into(), redirect_url: "https://app.linuxiarz.pl/_rustproxy/oidc/callback".into(),
redirect_url: "https://app.example.com/_rustproxy/oidc/callback".into(),
session_secret: "long-session-secret-at-least-32-characters".into(), session_secret: "long-session-secret-at-least-32-characters".into(),
..oidc::Settings::default() ..oidc::Settings::default()
}; };
assert!(Selection { assert!(Selection {
choice: None, choice: None,
basic_enabled: false, basic_enabled: false, tinyauth: &tinyauth,
tinyauth: &tinyauth,
authelia: &authelia::Settings::default(), authelia: &authelia::Settings::default(),
authentik: &authentik::Settings::default(), authentik: &authentik::Settings::default(), oidc: &oidc,
oidc: &oidc, }.validate().is_err());
}
.validate()
.is_err());
} }
#[test] #[test]
fn oidc_identity_provider_is_independent_of_integration_method() { fn oidc_identity_provider_is_independent_of_integration_method() {
let mut oidc = oidc::Settings::default(); let mut oidc = oidc::Settings::default();
oidc.enabled = true; oidc.enabled = true;
oidc.issuer_url = "https://auth.test.com".into(); oidc.issuer_url = "https://auth.gruszczynski.cc".into();
oidc.client_id = "rustproxy-test".into(); oidc.client_id = "rustproxy-test".into();
oidc.client_secret = "test-client-secret".into(); oidc.client_secret = "test-client-secret".into();
oidc.session_secret = "a-long-random-value-for-testing-oidc-sessions".into(); oidc.session_secret = "a-long-random-value-for-testing-oidc-sessions".into();
oidc.redirect_url = "https://app.example.com/_rustproxy/oidc/callback".into(); oidc.redirect_url = "https://app.linuxiarz.pl/_rustproxy/oidc/callback".into();
for provider in [ for provider in [IdentityProvider::Tinyauth, IdentityProvider::Authelia,
IdentityProvider::Tinyauth, IdentityProvider::Authentik, IdentityProvider::PocketId,
IdentityProvider::Authelia, IdentityProvider::Custom] {
IdentityProvider::Authentik,
IdentityProvider::PocketId,
IdentityProvider::Custom,
] {
assert!(Selection { assert!(Selection {
choice: Some(Choice { choice: Some(Choice { provider, method: IntegrationMethod::Oidc }),
provider,
method: IntegrationMethod::Oidc
}),
basic_enabled: false, basic_enabled: false,
tinyauth: &tinyauth::Settings::default(), tinyauth: &tinyauth::Settings::default(),
authelia: &authelia::Settings::default(), authelia: &authelia::Settings::default(),
authentik: &authentik::Settings::default(), authentik: &authentik::Settings::default(),
oidc: &oidc, oidc: &oidc,
} }.validate().is_ok());
.validate()
.is_ok());
} }
assert!(Selection { assert!(Selection {
choice: Some(Choice { choice: Some(Choice { provider: IdentityProvider::Authelia, method: IntegrationMethod::ForwardAuth }),
provider: IdentityProvider::Authelia, basic_enabled: false, tinyauth: &tinyauth::Settings::default(),
method: IntegrationMethod::ForwardAuth authelia: &authelia::Settings::default(), authentik: &authentik::Settings::default(),
}),
basic_enabled: false,
tinyauth: &tinyauth::Settings::default(),
authelia: &authelia::Settings::default(),
authentik: &authentik::Settings::default(),
oidc: &oidc, oidc: &oidc,
} }.validate().is_err());
.validate()
.is_err());
} }
#[test] #[test]
@@ -333,32 +303,16 @@ mod tests {
let authentik = authentik::Settings::default(); let authentik = authentik::Settings::default();
let oidc = oidc::Settings::default(); let oidc = oidc::Settings::default();
let selection = |choice| Selection { let selection = |choice| Selection {
choice, choice, basic_enabled: false, tinyauth: &tinyauth,
basic_enabled: false, authelia: &authelia, authentik: &authentik, oidc: &oidc,
tinyauth: &tinyauth,
authelia: &authelia,
authentik: &authentik,
oidc: &oidc,
}; };
assert!(selection(None).validate().is_ok()); assert!(selection(None).validate().is_ok());
assert!(selection(Some(Choice { assert!(selection(Some(Choice { provider: IdentityProvider::Tinyauth,
provider: IdentityProvider::Tinyauth, method: IntegrationMethod::ForwardAuth })).validate().is_ok());
method: IntegrationMethod::ForwardAuth assert!(selection(Some(Choice { provider: IdentityProvider::Authelia,
})) method: IntegrationMethod::ForwardAuth })).validate().is_err());
.validate() assert!(selection(Some(Choice { provider: IdentityProvider::Tinyauth,
.is_ok()); method: IntegrationMethod::Oidc })).validate().is_err());
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)] #[derive(Debug)]
+3 -2
View File
@@ -208,9 +208,10 @@ pub fn compile_with_active(
<= MAX_ROUTES, <= MAX_ROUTES,
"too many rules across all redirect hosts (max 256)" "too many rules across all redirect hosts (max 256)"
); );
let config_bytes = serde_json::to_vec(&config)?.len();
ensure!( ensure!(
serde_json::to_vec(&config)?.len() <= MAX_CONFIG_BYTES, config_bytes <= MAX_CONFIG_BYTES,
"configuration too large" "configuration too large ({config_bytes} bytes, maximum {MAX_CONFIG_BYTES} bytes)"
); );
let listeners = crate::vhost::listener_plan(&config, &desired_policy)?; let listeners = crate::vhost::listener_plan(&config, &desired_policy)?;
crate::stream::validate( crate::stream::validate(
+1 -1
View File
@@ -13,7 +13,7 @@ use std::{
}; };
use url::{Host, Url}; use url::{Host, Url};
pub const MAX_CONFIG_BYTES: usize = 262_144; pub const MAX_CONFIG_BYTES: usize = 2_097_152;
pub const MAX_BACKENDS: usize = 128; pub const MAX_BACKENDS: usize = 128;
pub const MAX_ROUTES: usize = 256; pub const MAX_ROUTES: usize = 256;
+38 -5
View File
@@ -9,6 +9,42 @@ mod tests {
admin_addr: "127.0.0.1:9090".parse().unwrap(), admin_addr: "127.0.0.1:9090".parse().unwrap(),
} }
} }
fn config_with_large_responses(count: usize) -> Config {
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
config.vhosts[0].routes = (0..count)
.map(|index| crate::vhost::PathRoute {
id: format!("large-{index}"),
path_prefix: format!("/large-{index}"),
response: Some(crate::vhost::LocationResponse {
body: "x".repeat(60_000),
..Default::default()
}),
..Default::default()
})
.collect();
config
}
#[test]
fn configuration_above_legacy_limit_compiles() {
let config = config_with_large_responses(5);
let bytes = serde_json::to_vec(&config).unwrap().len();
assert!(bytes > 262_144);
assert!(bytes <= MAX_CONFIG_BYTES);
assert!(compile(config, 1, &policy()).is_ok());
}
#[test]
fn configuration_above_new_limit_is_rejected() {
let config = config_with_large_responses(36);
assert!(serde_json::to_vec(&config).unwrap().len() > MAX_CONFIG_BYTES);
let error = match compile(config, 1, &policy()) {
Ok(_) => panic!("oversized configuration should be rejected"),
Err(error) => error,
};
assert!(error.to_string().contains("configuration too large"));
}
#[test] #[test]
fn route_handles_reuse_snapshot_ownership_without_cloning_compiled_routes() { fn route_handles_reuse_snapshot_ownership_without_cloning_compiled_routes() {
let snapshot = let snapshot =
@@ -80,17 +116,14 @@ mod tests {
method: crate::auth_provider::IntegrationMethod::Oidc, method: crate::auth_provider::IntegrationMethod::Oidc,
}); });
host.oidc.enabled = true; host.oidc.enabled = true;
host.oidc.issuer_url = "https://auth.test.com".into(); host.oidc.issuer_url = "https://auth.gruszczynski.cc".into();
host.oidc.client_id = "app".into(); host.oidc.client_id = "app".into();
host.oidc.client_secret = "client-secret".into(); host.oidc.client_secret = "client-secret".into();
host.oidc.session_secret = "a-unique-testing-session-secret-32-bytes".into(); host.oidc.session_secret = "a-unique-testing-session-secret-32-bytes".into();
host.oidc.redirect_url = "https://localhost/_rustproxy/oidc/callback".into(); host.oidc.redirect_url = "https://localhost/_rustproxy/oidc/callback".into();
assert!(host.validate().is_ok()); assert!(host.validate().is_ok());
let encoded = serde_json::to_value(&host).unwrap(); let encoded = serde_json::to_value(&host).unwrap();
assert_eq!( assert_eq!(encoded["authentication"], serde_json::json!({"provider":"authelia","method":"oidc"}));
encoded["authentication"],
serde_json::json!({"provider":"authelia","method":"oidc"})
);
let restored: crate::vhost::Vhost = serde_json::from_value(encoded.clone()).unwrap(); let restored: crate::vhost::Vhost = serde_json::from_value(encoded.clone()).unwrap();
assert_eq!(restored.authentication, host.authentication); assert_eq!(restored.authentication, host.authentication);
assert!(restored.validate().is_ok()); assert!(restored.validate().is_ok());
+2 -2
View File
@@ -398,7 +398,7 @@ async fn api(
async fn read_config_file(file: PathBuf) -> Result<Config> { async fn read_config_file(file: PathBuf) -> Result<Config> {
ensure!( ensure!(
tokio::fs::metadata(&file).await?.len() <= config::MAX_CONFIG_BYTES as u64, tokio::fs::metadata(&file).await?.len() <= config::MAX_CONFIG_BYTES as u64,
"config file exceeds 256 KiB" "config file exceeds size limit"
); );
let data = tokio::task::spawn_blocking(move || { let data = tokio::task::spawn_blocking(move || {
let file = std::fs::File::open(file)?; let file = std::fs::File::open(file)?;
@@ -407,7 +407,7 @@ async fn read_config_file(file: PathBuf) -> Result<Config> {
.read_to_end(&mut bytes)?; .read_to_end(&mut bytes)?;
anyhow::ensure!( anyhow::ensure!(
bytes.len() <= config::MAX_CONFIG_BYTES, bytes.len() <= config::MAX_CONFIG_BYTES,
"config file exceeds 256 KiB" "config file exceeds size limit"
); );
Ok::<_, anyhow::Error>(bytes) Ok::<_, anyhow::Error>(bytes)
}) })
+155 -509
View File
@@ -5,24 +5,13 @@ use crate::{
}; };
use anyhow::{ensure, Context as _, Result}; use anyhow::{ensure, Context as _, Result};
use axum::{body::Body, response::Response}; use axum::{body::Body, response::Response};
use base64::{ use base64::{engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, Engine as _};
engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
Engine as _,
};
use http::{header, HeaderMap, HeaderValue, Method, Request, StatusCode, Uri}; use http::{header, HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
use http_body_util::{BodyExt, Limited}; use http_body_util::{BodyExt, Limited};
use ring::{ use ring::{aead, digest, hmac, rand::{SecureRandom, SystemRandom}};
aead, digest, hmac,
rand::{SecureRandom, SystemRandom},
};
use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde::{de::DeserializeOwned, Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use std::{ use std::{fmt, net::SocketAddr, sync::Arc, time::{Duration, Instant, SystemTime, UNIX_EPOCH}};
fmt,
net::SocketAddr,
sync::Arc,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use url::{form_urlencoded, Url}; use url::{form_urlencoded, Url};
@@ -53,183 +42,87 @@ pub struct Settings {
impl Default for Settings { impl Default for Settings {
fn default() -> Self { fn default() -> Self {
Self { Self {
enabled: false, enabled: false, issuer_url: String::new(), backend_url: String::new(), client_id: String::new(),
issuer_url: String::new(), client_secret: String::new(), scopes: "openid profile email".into(), session_secret: String::new(), redirect_url: String::new(),
backend_url: String::new(), allow_private: false, address_allowlist: Vec::new(), tls_ca_pem: None,
client_id: String::new(), connect_timeout_seconds: 5, timeout_seconds: 10, session_ttl_seconds: 3600,
client_secret: String::new(),
scopes: "openid profile email".into(),
session_secret: String::new(),
redirect_url: String::new(),
allow_private: false,
address_allowlist: Vec::new(),
tls_ca_pem: None,
connect_timeout_seconds: 5,
timeout_seconds: 10,
session_ttl_seconds: 3600,
} }
} }
} }
fn https_url(raw: &str) -> Result<Url> { fn https_url(raw: &str) -> Result<Url> {
ensure!( ensure!(!raw.is_empty() && raw.len() <= 2048, "OIDC URL must be 1..2048 bytes");
!raw.is_empty() && raw.len() <= 2048,
"OIDC URL must be 1..2048 bytes"
);
let url = Url::parse(raw)?; let url = Url::parse(raw)?;
ensure!( ensure!(url.scheme() == "https" && url.host_str().is_some(), "OIDC requires HTTPS URLs");
url.scheme() == "https" && url.host_str().is_some(), 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");
"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) Ok(url)
} }
impl Settings { impl Settings {
pub fn validate(&self) -> Result<()> { pub fn validate(&self) -> Result<()> {
ensure!( ensure!((1..=30).contains(&self.connect_timeout_seconds)
(1..=30).contains(&self.connect_timeout_seconds) && (1..=60).contains(&self.timeout_seconds)
&& (1..=60).contains(&self.timeout_seconds) && self.connect_timeout_seconds <= self.timeout_seconds,
&& self.connect_timeout_seconds <= self.timeout_seconds, "OIDC timeouts must be 1..30s connect <= 1..60s total");
"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");
);
ensure!(
(60..=86400).contains(&self.session_ttl_seconds),
"OIDC session lifetime must be 60..86400 seconds"
);
if self.enabled { if self.enabled {
let issuer = https_url(&self.issuer_url)?; let issuer = https_url(&self.issuer_url)?;
ensure!(!issuer.path().contains(".."), "OIDC issuer path is invalid"); ensure!(!issuer.path().contains(".."), "OIDC issuer path is invalid");
if !self.backend_url.is_empty() { if !self.backend_url.is_empty() {
let internal = let internal = crate::forward_auth::http_url(&self.backend_url, "OIDC internal backend")?;
crate::forward_auth::http_url(&self.backend_url, "OIDC internal backend")?; ensure!(internal.path() == "/" && internal.query().is_none(),
ensure!( "OIDC internal backend URL must be an origin without a path or query");
internal.path() == "/" && internal.query().is_none(),
"OIDC internal backend URL must be an origin without a path or query"
);
} }
ensure!( ensure!(!self.client_id.is_empty() && self.client_id.len() <= 256
!self.client_id.is_empty() && self.client_id.bytes().all(|c| c.is_ascii_alphanumeric() || b"-._~".contains(&c)),
&& self.client_id.len() <= 256 "OIDC client ID must contain 1..256 URL-safe characters");
&& self ensure!(!self.client_secret.is_empty() && self.client_secret.len() <= 2048, "OIDC client secret required");
.client_id ensure!(self.session_secret.len() >= 32 && self.session_secret.len() <= 4096, "OIDC session secret must be at least 32 bytes");
.bytes() ensure!(self.scopes.len() <= 1024 && self.scopes.bytes().all(|c| c.is_ascii_alphanumeric() || b" -_:.".contains(&c)),
.all(|c| c.is_ascii_alphanumeric() || b"-._~".contains(&c)), "OIDC scopes cannot contain control characters");
"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(); let scopes: Vec<_> = self.scopes.split_whitespace().collect();
ensure!( ensure!(scopes.len() <= 16 && scopes.contains(&"openid")
scopes.len() <= 16 && scopes.iter().all(|item| item.len() <= 64 && item.bytes().all(|c| c.is_ascii_alphanumeric() || b"-_:.".contains(&c))),
&& scopes.contains(&"openid") "OIDC scopes must contain openid and valid space-separated names");
&& 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)?; let redirect = https_url(&self.redirect_url)?;
ensure!( ensure!(redirect.path() == CALLBACK, "OIDC redirect URL must end with {CALLBACK}");
redirect.path() == CALLBACK,
"OIDC redirect URL must end with {CALLBACK}"
);
} }
Ok(()) Ok(())
} }
pub fn compile( pub fn compile(&self, policy: &ValidationPolicy, bindings: &[SocketAddr]) -> Result<Option<Compiled>> {
&self,
policy: &ValidationPolicy,
bindings: &[SocketAddr],
) -> Result<Option<Compiled>> {
self.validate()?; self.validate()?;
if !self.enabled { if !self.enabled { return Ok(None); }
return Ok(None);
}
let issuer = https_url(&self.issuer_url)?; let issuer = https_url(&self.issuer_url)?;
let redirect = https_url(&self.redirect_url)?; let redirect = https_url(&self.redirect_url)?;
let upstream_origin = if self.backend_url.is_empty() { let upstream_origin = if self.backend_url.is_empty() {
issuer.origin().ascii_serialization() issuer.origin().ascii_serialization()
} else { } else {
crate::forward_auth::http_url(&self.backend_url, "OIDC internal backend")? crate::forward_auth::http_url(&self.backend_url, "OIDC internal backend")?.origin().ascii_serialization()
.origin()
.ascii_serialization()
}; };
let public_host: Uri = issuer.as_str().parse()?; let public_host: Uri = issuer.as_str().parse()?;
let host_header = HeaderValue::from_str( let host_header = HeaderValue::from_str(public_host.authority().context("OIDC issuer authority missing")?.as_str())?;
public_host
.authority()
.context("OIDC issuer authority missing")?
.as_str(),
)?;
let definition = Backend { let definition = Backend {
id: "oidc".into(), id: "oidc".into(), url: upstream_origin.clone(),
url: upstream_origin.clone(), allow_private: self.allow_private, accept_local_listener_risk: false,
allow_private: self.allow_private, preserve_host: false, protocol: UpstreamProtocol::Http1,
accept_local_listener_risk: false,
preserve_host: false,
protocol: UpstreamProtocol::Http1,
address_allowlist: self.address_allowlist.clone(), address_allowlist: self.address_allowlist.clone(),
tls_ca_pem: self.tls_ca_pem.clone(), tls_ca_pem: self.tls_ca_pem.clone(), tls_server_name: None, tls_verify: true,
tls_server_name: None,
tls_verify: true,
}; };
let keepalive = crate::balance::Keepalive::default(); let keepalive = crate::balance::Keepalive::default();
let mut backend = let mut backend = crate::config::compile_backend(&definition, policy, bindings, &keepalive)?;
crate::config::compile_backend(&definition, policy, bindings, &keepalive)?;
backend.client = crate::upstream::client_with_timeout( backend.client = crate::upstream::client_with_timeout(
&definition, &definition, backend.address_policy.clone(), false, &keepalive,
backend.address_policy.clone(),
false,
&keepalive,
Duration::from_secs(self.connect_timeout_seconds), Duration::from_secs(self.connect_timeout_seconds),
)?; )?;
let fingerprint = digest::digest( let fingerprint = digest::digest(&digest::SHA256,
&digest::SHA256, format!("{}:{}", self.client_id, self.redirect_url).as_bytes());
format!("{}:{}", self.client_id, self.redirect_url).as_bytes(), let suffix = fingerprint.as_ref()[..8].iter().map(|c| format!("{c:02x}")).collect::<Vec<_>>().join("");
);
let suffix = fingerprint.as_ref()[..8]
.iter()
.map(|c| format!("{c:02x}"))
.collect::<Vec<_>>()
.join("");
Ok(Some(Compiled { Ok(Some(Compiled {
provider_id: "oidc", provider_id: "oidc", provider_label: "OIDC",
provider_label: "OIDC", issuer: self.issuer_url.clone(), client_id: self.client_id.clone(), scopes: self.scopes.clone(),
issuer: self.issuer_url.clone(),
client_id: self.client_id.clone(),
scopes: self.scopes.clone(),
client_secret: self.client_secret.clone(), client_secret: self.client_secret.clone(),
session_secret: scoped_secret( session_secret: scoped_secret(&self.session_secret, &self.issuer_url, &self.client_id, &self.redirect_url),
&self.session_secret, redirect, backend: Arc::new(backend), cache: Mutex::new(None),
&self.issuer_url, upstream_origin: Url::parse(&upstream_origin)?, host_header,
&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(), internal_override: !self.backend_url.is_empty(),
timeout: Duration::from_secs(self.timeout_seconds), timeout: Duration::from_secs(self.timeout_seconds),
session_ttl: self.session_ttl_seconds, session_ttl: self.session_ttl_seconds,
@@ -279,10 +172,8 @@ impl Compiled {
} }
impl fmt::Debug for Compiled { impl fmt::Debug for Compiled {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OidcProvider") f.debug_struct("OidcProvider").field("issuer", &self.issuer)
.field("issuer", &self.issuer) .field("redirect", &self.redirect).finish_non_exhaustive()
.field("redirect", &self.redirect)
.finish_non_exhaustive()
} }
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@@ -308,9 +199,7 @@ fn now() -> Result<u64> {
fn random_b64(bytes: usize) -> Result<String> { fn random_b64(bytes: usize) -> Result<String> {
ensure!(bytes <= 128, "random input too long"); ensure!(bytes <= 128, "random input too long");
let mut random = vec![0u8; bytes]; let mut random = vec![0u8; bytes];
SystemRandom::new() SystemRandom::new().fill(&mut random).map_err(|_| anyhow::anyhow!("random source unavailable"))?;
.fill(&mut random)
.map_err(|_| anyhow::anyhow!("random source unavailable"))?;
Ok(URL_SAFE_NO_PAD.encode(random)) Ok(URL_SAFE_NO_PAD.encode(random))
} }
fn scoped_secret(secret: &str, issuer: &str, client_id: &str, redirect: &str) -> String { fn scoped_secret(secret: &str, issuer: &str, client_id: &str, redirect: &str) -> String {
@@ -320,25 +209,17 @@ fn scoped_secret(secret: &str, issuer: &str, client_id: &str, redirect: &str) ->
} }
fn crypt_key(secret: &str) -> Result<aead::LessSafeKey> { fn crypt_key(secret: &str) -> Result<aead::LessSafeKey> {
let hash = digest::digest(&digest::SHA256, secret.as_bytes()); let hash = digest::digest(&digest::SHA256, secret.as_bytes());
Ok(aead::LessSafeKey::new( Ok(aead::LessSafeKey::new(aead::UnboundKey::new(&aead::AES_256_GCM, hash.as_ref()).map_err(|_| anyhow::anyhow!("OIDC cipher initialization failed"))?))
aead::UnboundKey::new(&aead::AES_256_GCM, hash.as_ref())
.map_err(|_| anyhow::anyhow!("OIDC cipher initialization failed"))?,
))
} }
fn seal<T: Serialize>(secret: &str, value: &T) -> Result<String> { fn seal<T: Serialize>(secret: &str, value: &T) -> Result<String> {
let mut nonce_bytes = [0u8; 12]; let mut nonce_bytes = [0u8; 12];
SystemRandom::new() SystemRandom::new().fill(&mut nonce_bytes).map_err(|_| anyhow::anyhow!("random source unavailable"))?;
.fill(&mut nonce_bytes)
.map_err(|_| anyhow::anyhow!("random source unavailable"))?;
let mut bytes = serde_json::to_vec(value)?; let mut bytes = serde_json::to_vec(value)?;
ensure!(bytes.len() <= 3072, "OIDC cookie contents too large"); ensure!(bytes.len() <= 3072, "OIDC cookie contents too large");
crypt_key(secret)? crypt_key(secret)?.seal_in_place_append_tag(
.seal_in_place_append_tag( aead::Nonce::assume_unique_for_key(nonce_bytes),
aead::Nonce::assume_unique_for_key(nonce_bytes), aead::Aad::from("rustproxy-oidc-v1"), &mut bytes,
aead::Aad::from("rustproxy-oidc-v1"), ).map_err(|_| anyhow::anyhow!("OIDC cookie encryption failure"))?;
&mut bytes,
)
.map_err(|_| anyhow::anyhow!("OIDC cookie encryption failure"))?;
let mut data = nonce_bytes.to_vec(); let mut data = nonce_bytes.to_vec();
data.extend_from_slice(&bytes); data.extend_from_slice(&bytes);
let encoded = URL_SAFE_NO_PAD.encode(data); let encoded = URL_SAFE_NO_PAD.encode(data);
@@ -351,13 +232,10 @@ fn open<T: DeserializeOwned>(secret: &str, encoded: &str) -> Result<T> {
ensure!(bytes.len() > 28, "invalid OIDC cookie"); ensure!(bytes.len() > 28, "invalid OIDC cookie");
let mut nonce = [0u8; 12]; let mut nonce = [0u8; 12];
nonce.copy_from_slice(&bytes[..12]); nonce.copy_from_slice(&bytes[..12]);
let plaintext = crypt_key(secret)? let plaintext = crypt_key(secret)?.open_in_place(
.open_in_place( aead::Nonce::assume_unique_for_key(nonce),
aead::Nonce::assume_unique_for_key(nonce), aead::Aad::from("rustproxy-oidc-v1"), &mut bytes[12..],
aead::Aad::from("rustproxy-oidc-v1"), ).map_err(|_| anyhow::anyhow!("invalid OIDC cookie authentication"))?;
&mut bytes[12..],
)
.map_err(|_| anyhow::anyhow!("invalid OIDC cookie authentication"))?;
Ok(serde_json::from_slice(plaintext)?) Ok(serde_json::from_slice(plaintext)?)
} }
fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
@@ -366,9 +244,7 @@ fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
for entry in line.to_str().ok()?.split(';') { for entry in line.to_str().ok()?.split(';') {
if let Some((key, value)) = entry.trim().split_once('=') { if let Some((key, value)) = entry.trim().split_once('=') {
if key == name { if key == name {
if selected.is_some() { if selected.is_some() { return None; }
return None;
}
selected = Some(value); selected = Some(value);
} }
} }
@@ -380,27 +256,19 @@ fn cookie(name: &str, value: &str, age: u64) -> Result<HeaderValue> {
Ok(format!("{name}={value}; Path=/; Max-Age={age}; HttpOnly; Secure; SameSite=Lax").parse()?) Ok(format!("{name}={value}; Path=/; Max-Age={age}; HttpOnly; Secure; SameSite=Lax").parse()?)
} }
fn clear_cookie(name: &str) -> HeaderValue { fn clear_cookie(name: &str) -> HeaderValue {
format!("{name}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax") format!("{name}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax").parse().expect("static valid cookie name")
.parse()
.expect("static valid cookie name")
} }
fn redirect(location: &str, status: StatusCode) -> Response { fn redirect(location: &str, status: StatusCode) -> Response {
let mut response = auth_provider::deny(status); let mut response = auth_provider::deny(status);
match location.parse() { match location.parse() {
Ok(value) => { Ok(value) => { response.headers_mut().insert(header::LOCATION, value); }
response.headers_mut().insert(header::LOCATION, value); Err(_) => { return auth_provider::deny(StatusCode::BAD_GATEWAY); }
}
Err(_) => {
return auth_provider::deny(StatusCode::BAD_GATEWAY);
}
} }
response response
} }
fn sanitize_header(headers: &mut HeaderMap, name: &'static str, value: &str) { fn sanitize_header(headers: &mut HeaderMap, name: &'static str, value: &str) {
if value.len() <= 2048 { if value.len() <= 2048 {
if let Ok(value) = value.parse() { if let Ok(value) = value.parse() { headers.insert(name, value); }
headers.insert(name, value);
}
} }
} }
fn identity(session: &Session, issuer: &str) -> Identity { fn identity(session: &Session, issuer: &str) -> Identity {
@@ -408,31 +276,19 @@ fn identity(session: &Session, issuer: &str) -> Identity {
sanitize_header(&mut headers, "remote-sub", &session.subject); sanitize_header(&mut headers, "remote-sub", &session.subject);
sanitize_header(&mut headers, "remote-issuer", issuer); sanitize_header(&mut headers, "remote-issuer", issuer);
sanitize_header(&mut headers, "remote-user", &session.username); sanitize_header(&mut headers, "remote-user", &session.username);
if let Some(email) = &session.email { if let Some(email) = &session.email { sanitize_header(&mut headers, "remote-email", 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(",")); }
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 } Identity { headers }
} }
impl Compiled { impl Compiled {
fn url_for_endpoint(&self, raw: &str) -> Result<Url> { fn url_for_endpoint(&self, raw: &str) -> Result<Url> {
let url = https_url(raw)?; let url = https_url(raw)?;
ensure!( ensure!(url.origin() == https_url(&self.issuer)?.origin(), "OIDC endpoints must use the configured issuer origin");
url.origin() == https_url(&self.issuer)?.origin(),
"OIDC endpoints must use the configured issuer origin"
);
Ok(url) Ok(url)
} }
fn wire_uri(&self, public: &Url) -> Result<Uri> { fn wire_uri(&self, public: &Url) -> Result<Uri> {
ensure!( ensure!(public.origin() == https_url(&self.issuer)?.origin(), "OIDC request not on issuer origin");
public.origin() == https_url(&self.issuer)?.origin(),
"OIDC request not on issuer origin"
);
let mut wire = self.upstream_origin.clone(); let mut wire = self.upstream_origin.clone();
wire.set_path(public.path()); wire.set_path(public.path());
wire.set_query(public.query()); wire.set_query(public.query());
@@ -445,145 +301,64 @@ impl Compiled {
} }
} }
async fn get_json(&self, url: &Url) -> Result<Value> { async fn get_json(&self, url: &Url) -> Result<Value> {
let mut request = Request::builder() let mut request = Request::builder().method(Method::GET).uri(self.wire_uri(url)?)
.method(Method::GET)
.uri(self.wire_uri(url)?)
.body(Body::empty())?; .body(Body::empty())?;
self.wire_headers(request.headers_mut()); self.wire_headers(request.headers_mut());
request request.headers_mut().insert(header::ACCEPT, HeaderValue::from_static("application/json"));
.headers_mut()
.insert(header::ACCEPT, HeaderValue::from_static("application/json"));
let response = self.backend.client.request(request).await?; let response = self.backend.client.request(request).await?;
ensure!( ensure!(response.status() == StatusCode::OK, "OIDC endpoint returned an error");
response.status() == StatusCode::OK, let body = Limited::new(response.into_body(), 65536).collect().await
"OIDC endpoint returned an error" .map_err(|error| anyhow::anyhow!("invalid OIDC endpoint response: {error}"))?.to_bytes();
);
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)?) Ok(serde_json::from_slice(&body)?)
} }
async fn discovery(&self) -> Result<Discovery> { async fn discovery(&self) -> Result<Discovery> {
let mut guard = self.cache.lock().await; let mut guard = self.cache.lock().await;
if let Some(saved) = guard.as_ref() { if let Some(saved) = guard.as_ref() {
if saved.until > Instant::now() { if saved.until > Instant::now() { return Ok(saved.discovery.clone()); }
return Ok(saved.discovery.clone());
}
} }
let discovery_url = self.url_for_endpoint(&format!( let discovery_url = self.url_for_endpoint(&format!("{}/.well-known/openid-configuration", self.issuer.trim_end_matches('/')))?;
"{}/.well-known/openid-configuration",
self.issuer.trim_end_matches('/')
))?;
let metadata = self.get_json(&discovery_url).await?; let metadata = self.get_json(&discovery_url).await?;
ensure!( ensure!(metadata.get("issuer").and_then(Value::as_str) == Some(self.issuer.as_str()), "OIDC discovery issuer mismatch");
metadata.get("issuer").and_then(Value::as_str) == Some(self.issuer.as_str()), let authorization = self.url_for_endpoint(metadata.get("authorization_endpoint").and_then(Value::as_str).context("missing OIDC authorization endpoint")?)?;
"OIDC discovery issuer mismatch" 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)
let authorization = self.url_for_endpoint( .map(|url| self.url_for_endpoint(url)).transpose()?;
metadata let jwks_url = self.url_for_endpoint(metadata.get("jwks_uri").and_then(Value::as_str).context("missing OIDC JWKS URI")?)?;
.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?; let jwks = self.get_json(&jwks_url).await?;
ensure!( ensure!(jwks.get("keys").and_then(Value::as_array).is_some(), "invalid OIDC signing keys");
jwks.get("keys").and_then(Value::as_array).is_some(), let discovery = Discovery { authorization, token, userinfo, jwks };
"invalid OIDC signing keys" *guard = Some(Cached { until: Instant::now() + Duration::from_secs(300), discovery: discovery.clone() });
);
let discovery = Discovery {
authorization,
token,
userinfo,
jwks,
};
*guard = Some(Cached {
until: Instant::now() + Duration::from_secs(300),
discovery: discovery.clone(),
});
Ok(discovery) Ok(discovery)
} }
fn is_redirect_host(&self, context: &forwarding::Context) -> bool { fn is_redirect_host(&self, context: &forwarding::Context) -> bool {
context.scheme == "https" context.scheme == "https" && Url::parse(&context.original_url)
&& Url::parse(&context.original_url) .is_ok_and(|url| url.origin() == self.redirect.origin())
.is_ok_and(|url| url.origin() == self.redirect.origin())
} }
async fn initiate( async fn initiate(&self, context: &forwarding::Context, cookies: &CookieSink) -> Result<Response> {
&self, ensure!(self.is_redirect_host(context), "OIDC request origin does not match callback");
context: &forwarding::Context,
cookies: &CookieSink,
) -> Result<Response> {
ensure!(
self.is_redirect_host(context),
"OIDC request origin does not match callback"
);
let destination = Url::parse(&context.original_url)?; let destination = Url::parse(&context.original_url)?;
let mut return_to = destination.path().to_owned(); let mut return_to = destination.path().to_owned();
if let Some(query) = destination.query() { if let Some(query) = destination.query() { return_to.push('?'); return_to.push_str(query); }
return_to.push('?'); ensure!(return_to.len() <= 1800 && return_to.starts_with('/') && !return_to.starts_with("//"), "invalid OIDC return URL");
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 { let transaction = Pending {
state: random_b64(32)?, state: random_b64(32)?, nonce: random_b64(32)?, verifier: random_b64(32)?,
nonce: random_b64(32)?, return_to, issued: now()?,
verifier: random_b64(32)?,
return_to,
issued: now()?,
}; };
let mut authorization = self.discovery().await?.authorization; let mut authorization = self.discovery().await?.authorization;
let challenge = digest::digest(&digest::SHA256, transaction.verifier.as_bytes()); let challenge = digest::digest(&digest::SHA256, transaction.verifier.as_bytes());
authorization authorization.query_pairs_mut().append_pair("response_type", "code")
.query_pairs_mut()
.append_pair("response_type", "code")
.append_pair("client_id", &self.client_id) .append_pair("client_id", &self.client_id)
.append_pair("redirect_uri", self.redirect.as_str()) .append_pair("redirect_uri", self.redirect.as_str())
.append_pair("scope", &self.scopes) .append_pair("scope", &self.scopes)
.append_pair("state", &transaction.state) .append_pair("state", &transaction.state)
.append_pair("nonce", &transaction.nonce) .append_pair("nonce", &transaction.nonce)
.append_pair("code_challenge_method", "S256") .append_pair("code_challenge_method", "S256")
.append_pair( .append_pair("code_challenge", &URL_SAFE_NO_PAD.encode(challenge.as_ref()));
"code_challenge",
&URL_SAFE_NO_PAD.encode(challenge.as_ref()),
);
let mut response = redirect(authorization.as_str(), StatusCode::FOUND); let mut response = redirect(authorization.as_str(), StatusCode::FOUND);
let mut set_cookie = HeaderMap::new(); let mut set_cookie = HeaderMap::new();
set_cookie.append( set_cookie.append(header::SET_COOKIE, cookie(&self.state_cookie, &seal(&self.session_secret, &transaction)?, 300)?);
header::SET_COOKIE, ensure!(cookies.collect(&set_cookie), "OIDC transaction cookie too large");
cookie( response.headers_mut().insert("referrer-policy", HeaderValue::from_static("no-referrer"));
&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) Ok(response)
} }
async fn exchange(&self, code: &str, pending: &Pending) -> Result<token::Claims> { async fn exchange(&self, code: &str, pending: &Pending) -> Result<token::Claims> {
@@ -594,89 +369,38 @@ impl Compiled {
.append_pair("redirect_uri", self.redirect.as_str()) .append_pair("redirect_uri", self.redirect.as_str())
.append_pair("code_verifier", &pending.verifier) .append_pair("code_verifier", &pending.verifier)
.finish(); .finish();
let id_encoded: String = let id_encoded: String = form_urlencoded::byte_serialize(self.client_id.as_bytes()).collect();
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 secret_encoded: String =
form_urlencoded::byte_serialize(self.client_secret.as_bytes()).collect();
let authorization = STANDARD.encode(format!("{id_encoded}:{secret_encoded}")); let authorization = STANDARD.encode(format!("{id_encoded}:{secret_encoded}"));
let mut request = Request::builder() let mut request = Request::builder().method(Method::POST).uri(self.wire_uri(&discovery.token)?)
.method(Method::POST)
.uri(self.wire_uri(&discovery.token)?)
.body(Body::from(body))?; .body(Body::from(body))?;
self.wire_headers(request.headers_mut()); self.wire_headers(request.headers_mut());
request.headers_mut().insert( request.headers_mut().insert(header::AUTHORIZATION, format!("Basic {authorization}").parse()?);
header::AUTHORIZATION, request.headers_mut().insert(header::ACCEPT, HeaderValue::from_static("application/json"));
format!("Basic {authorization}").parse()?, request.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("application/x-www-form-urlencoded"));
);
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?; let response = self.backend.client.request(request).await?;
ensure!( ensure!(response.status() == StatusCode::OK, "OIDC token endpoint returned an error");
response.status() == StatusCode::OK, let body = Limited::new(response.into_body(), 65536).collect().await
"OIDC token endpoint returned an error" .map_err(|error| anyhow::anyhow!("invalid OIDC token response: {error}"))?.to_bytes();
);
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 value: Value = serde_json::from_slice(&body)?;
let id_token = value let id_token = value.get("id_token").and_then(Value::as_str).context("missing OIDC ID token")?;
.get("id_token") let mut claims = token::verify_id_token(id_token, &discovery.jwks, &self.issuer, &self.client_id, &pending.nonce)?;
.and_then(Value::as_str) if let (Some(userinfo_url), Some(access_token)) = (discovery.userinfo.as_ref(), value.get("access_token").and_then(Value::as_str)) {
.context("missing OIDC ID token")?; ensure!(access_token.len() <= 8192 && !access_token.is_empty(), "invalid OIDC access token");
let mut claims = token::verify_id_token( let mut request = Request::builder().method(Method::GET).uri(self.wire_uri(userinfo_url)?)
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())?; .body(Body::empty())?;
self.wire_headers(request.headers_mut()); self.wire_headers(request.headers_mut());
request.headers_mut().insert( request.headers_mut().insert(header::AUTHORIZATION, format!("Bearer {access_token}").parse()?);
header::AUTHORIZATION, request.headers_mut().insert(header::ACCEPT, HeaderValue::from_static("application/json"));
format!("Bearer {access_token}").parse()?,
);
request
.headers_mut()
.insert(header::ACCEPT, HeaderValue::from_static("application/json"));
let response = self.backend.client.request(request).await?; let response = self.backend.client.request(request).await?;
ensure!( ensure!(response.status() == StatusCode::OK, "OIDC UserInfo endpoint returned an error");
response.status() == StatusCode::OK, let data = Limited::new(response.into_body(), 65536).collect().await
"OIDC UserInfo endpoint returned an error" .map_err(|error| anyhow::anyhow!("invalid OIDC UserInfo response: {error}"))?.to_bytes();
);
let data = Limited::new(response.into_body(), 65536)
.collect()
.await
.map_err(|error| anyhow::anyhow!("invalid OIDC UserInfo response: {error}"))?
.to_bytes();
token::merge_userinfo(&mut claims, &serde_json::from_slice::<Value>(&data)?)?; token::merge_userinfo(&mut claims, &serde_json::from_slice::<Value>(&data)?)?;
} }
Ok(claims) Ok(claims)
} }
async fn callback( async fn callback(&self, request: Request<Body>, context: &forwarding::Context, shutdown: &CancellationToken) -> Response {
&self,
request: Request<Body>,
context: &forwarding::Context,
shutdown: &CancellationToken,
) -> Response {
if request.method() != Method::GET || !self.is_redirect_host(context) { if request.method() != Method::GET || !self.is_redirect_host(context) {
return auth_provider::deny(StatusCode::BAD_REQUEST); return auth_provider::deny(StatusCode::BAD_REQUEST);
} }
@@ -684,20 +408,14 @@ impl Compiled {
let mut valid_state = None; let mut valid_state = None;
let mut error = false; let mut error = false;
if let Some(query) = request.uri().query() { if let Some(query) = request.uri().query() {
if query.len() > 8192 { if query.len() > 8192 { return auth_provider::deny(StatusCode::BAD_REQUEST); }
return auth_provider::deny(StatusCode::BAD_REQUEST);
}
for (key, value) in form_urlencoded::parse(query.as_bytes()) { for (key, value) in form_urlencoded::parse(query.as_bytes()) {
match key.as_ref() { match key.as_ref() {
"code" if valid_code.is_none() && value.len() <= 4096 => { "code" if valid_code.is_none() && value.len() <= 4096 => valid_code = Some(value.into_owned()),
valid_code = Some(value.into_owned()) "state" if valid_state.is_none() && value.len() <= 256 => valid_state = Some(value.into_owned()),
}
"state" if valid_state.is_none() && value.len() <= 256 => {
valid_state = Some(value.into_owned())
}
"error" => error = true, "error" => error = true,
"code" | "state" => error = true, "code" | "state" => error = true,
_ => {} _ => {},
} }
} }
} }
@@ -708,18 +426,11 @@ impl Compiled {
Ok(value) => value, Ok(value) => value,
Err(_) => return auth_provider::deny(StatusCode::UNAUTHORIZED), Err(_) => return auth_provider::deny(StatusCode::UNAUTHORIZED),
}; };
let current = match now() { let current = match now() { Ok(now) => now, Err(_) => return auth_provider::deny(StatusCode::BAD_GATEWAY) };
Ok(now) => now, if error || current < pending.issued || current.saturating_sub(pending.issued) > 300
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_state.as_deref() != Some(pending.state.as_str())
|| valid_code.as_deref().is_none_or(str::is_empty) || valid_code.as_deref().is_none_or(str::is_empty)
{ { return auth_provider::deny(StatusCode::UNAUTHORIZED); }
return auth_provider::deny(StatusCode::UNAUTHORIZED);
}
let code = valid_code.expect("validated authorization code"); let code = valid_code.expect("validated authorization code");
let result = tokio::select! { let result = tokio::select! {
_ = shutdown.cancelled() => return auth_provider::deny(StatusCode::SERVICE_UNAVAILABLE), _ = shutdown.cancelled() => return auth_provider::deny(StatusCode::SERVICE_UNAVAILABLE),
@@ -729,90 +440,44 @@ impl Compiled {
Ok(Ok(claims)) => claims, Ok(Ok(claims)) => claims,
Ok(Err(error)) => { Ok(Err(error)) => {
tracing::warn!(provider = self.provider_id, %error, "OIDC callback verification failed"); tracing::warn!(provider = self.provider_id, %error, "OIDC callback verification failed");
return auth_provider::gateway_failure( return auth_provider::gateway_failure(self.provider_label, GatewayFailure::Verification);
self.provider_label,
GatewayFailure::Verification,
);
}
Err(_) => {
return auth_provider::gateway_failure(self.provider_label, GatewayFailure::Timeout)
} }
Err(_) => return auth_provider::gateway_failure(self.provider_label, GatewayFailure::Timeout),
}; };
let session = Session { let session = Session {
subject: claims.subject, subject: claims.subject, username: claims.username, email: claims.email,
username: claims.username, name: claims.name, groups: claims.groups,
email: claims.email,
name: claims.name,
groups: claims.groups,
expires: current.saturating_add(self.session_ttl), expires: current.saturating_add(self.session_ttl),
}; };
let value = match seal(&self.session_secret, &session) { let value = match seal(&self.session_secret, &session) {
Ok(value) => value, Ok(value) => value,
Err(_) => { Err(_) => return auth_provider::gateway_failure(self.provider_label, GatewayFailure::Verification),
return auth_provider::gateway_failure(
self.provider_label,
GatewayFailure::Verification,
)
}
}; };
let mut response = redirect(&pending.return_to, StatusCode::SEE_OTHER); let mut response = redirect(&pending.return_to, StatusCode::SEE_OTHER);
response response.headers_mut().append(header::SET_COOKIE, clear_cookie(&self.state_cookie));
.headers_mut() response.headers_mut().insert("referrer-policy", HeaderValue::from_static("no-referrer"));
.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) { if let Ok(cookie) = cookie(&self.session_cookie, &value, self.session_ttl) {
response.headers_mut().append(header::SET_COOKIE, cookie); response.headers_mut().append(header::SET_COOKIE, cookie);
} else { } else { return auth_provider::deny(StatusCode::BAD_GATEWAY); }
return auth_provider::deny(StatusCode::BAD_GATEWAY);
}
response response
} }
} }
impl auth_provider::Provider for Compiled { impl auth_provider::Provider for Compiled {
fn id(&self) -> &'static str { fn id(&self) -> &'static str { self.provider_id }
self.provider_id fn method(&self) -> auth_provider::IntegrationMethod { auth_provider::IntegrationMethod::Oidc }
}
fn method(&self) -> auth_provider::IntegrationMethod {
auth_provider::IntegrationMethod::Oidc
}
fn strip_identity(&self, headers: &mut HeaderMap) { fn strip_identity(&self, headers: &mut HeaderMap) {
let names: Vec<_> = headers let names: Vec<_> = headers.keys().filter(|name| {
.keys() let name = name.as_str();
.filter(|name| { name.starts_with("remote-") || name.starts_with("x-authentik-") || name.starts_with("x-auth-request-")
let name = name.as_str(); || matches!(name, "x-forwarded-user" | "x-forwarded-email" | "x-remote-user" | "x-remote-email" | "x-user" | "x-email")
name.starts_with("remote-") }).cloned().collect();
|| name.starts_with("x-authentik-") for name in names { headers.remove(name); }
|| 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>( fn authorize<'a>(&'a self, incoming: &'a HeaderMap, method: &'a Method,
&'a self, context: &'a forwarding::Context, shutdown: &'a CancellationToken,
incoming: &'a HeaderMap, cookies: &'a CookieSink) -> auth_provider::Authorization<'a> {
method: &'a Method,
context: &'a forwarding::Context,
shutdown: &'a CancellationToken,
cookies: &'a CookieSink,
) -> auth_provider::Authorization<'a> {
Box::pin(async move { Box::pin(async move {
if !self.is_redirect_host(context) { if !self.is_redirect_host(context) { return Err(auth_provider::deny(StatusCode::FORBIDDEN)); }
return Err(auth_provider::deny(StatusCode::FORBIDDEN));
}
if let Some(encoded) = cookie_value(incoming, &self.session_cookie) { if let Some(encoded) = cookie_value(incoming, &self.session_cookie) {
if let Ok(session) = open::<Session>(&self.session_secret, encoded) { if let Ok(session) = open::<Session>(&self.session_secret, encoded) {
if now().is_ok_and(|current| session.expires > current) { if now().is_ok_and(|current| session.expires > current) {
@@ -831,44 +496,25 @@ impl auth_provider::Provider for Compiled {
Ok(Ok(response)) => response, Ok(Ok(response)) => response,
Ok(Err(error)) => { Ok(Err(error)) => {
tracing::warn!(provider = self.provider_id, %error, "OIDC login initiation failed"); tracing::warn!(provider = self.provider_id, %error, "OIDC login initiation failed");
auth_provider::gateway_failure( auth_provider::gateway_failure(self.provider_label, GatewayFailure::Verification)
self.provider_label,
GatewayFailure::Verification,
)
}
Err(_) => {
auth_provider::gateway_failure(self.provider_label, GatewayFailure::Timeout)
} }
Err(_) => auth_provider::gateway_failure(self.provider_label, GatewayFailure::Timeout),
}) })
}) })
} }
fn reserved_path(&self, path: &str) -> bool { fn reserved_path(&self, path: &str) -> bool { matches!(path, CALLBACK | LOGOUT) }
matches!(path, CALLBACK | LOGOUT) fn serve_reserved<'a>(&'a self, request: Request<Body>, context: &'a forwarding::Context,
} shutdown: &'a CancellationToken) -> Option<auth_provider::Route<'a>> {
fn serve_reserved<'a>(
&'a self,
request: Request<Body>,
context: &'a forwarding::Context,
shutdown: &'a CancellationToken,
) -> Option<auth_provider::Route<'a>> {
match request.uri().path() { match request.uri().path() {
CALLBACK => Some(Box::pin(async move { CALLBACK => Some(Box::pin(async move { self.callback(request, context, shutdown).await })),
self.callback(request, context, shutdown).await
})),
LOGOUT => Some(Box::pin(async move { LOGOUT => Some(Box::pin(async move {
if request.method() != Method::GET || !self.is_redirect_host(context) { if request.method() != Method::GET || !self.is_redirect_host(context) {
return auth_provider::deny(StatusCode::BAD_REQUEST); return auth_provider::deny(StatusCode::BAD_REQUEST);
} }
let mut response = redirect("/", StatusCode::SEE_OTHER); let mut response = redirect("/", StatusCode::SEE_OTHER);
response response.headers_mut().append(header::SET_COOKIE, clear_cookie(&self.session_cookie));
.headers_mut() response.headers_mut().append(header::SET_COOKIE, clear_cookie(&self.state_cookie));
.append(header::SET_COOKIE, clear_cookie(&self.session_cookie)); response.headers_mut().insert("referrer-policy", HeaderValue::from_static("no-referrer"));
response
.headers_mut()
.append(header::SET_COOKIE, clear_cookie(&self.state_cookie));
response
.headers_mut()
.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
response response
})), })),
_ => None, _ => None,
+37 -120
View File
@@ -6,11 +6,11 @@ mod tests {
fn enabled() -> Settings { fn enabled() -> Settings {
Settings { Settings {
enabled: true, enabled: true,
issuer_url: "https://auth.test.com".into(), issuer_url: "https://auth.gruszczynski.cc".into(),
client_id: "rustproxy-app".into(), client_id: "rustproxy-app".into(),
client_secret: "super-secret-client".into(), client_secret: "super-secret-client".into(),
session_secret: "an-uncorrelated-high-entropy-session-secret-value".into(), session_secret: "an-uncorrelated-high-entropy-session-secret-value".into(),
redirect_url: "https://app.example.com/_rustproxy/oidc/callback".into(), redirect_url: "https://app.linuxiarz.pl/_rustproxy/oidc/callback".into(),
..Settings::default() ..Settings::default()
} }
} }
@@ -20,14 +20,8 @@ mod tests {
assert!(!Settings::default().enabled); assert!(!Settings::default().enabled);
let settings = enabled(); let settings = enabled();
assert!(settings.validate().is_ok()); assert!(settings.validate().is_ok());
assert_eq!( assert_eq!(https_url(&settings.issuer_url).unwrap().host_str(), Some("auth.gruszczynski.cc"));
https_url(&settings.issuer_url).unwrap().host_str(), assert_eq!(https_url(&settings.redirect_url).unwrap().host_str(), Some("app.linuxiarz.pl"));
Some("https://auth.test.com")
);
assert_eq!(
https_url(&settings.redirect_url).unwrap().host_str(),
Some("app.example.com")
);
} }
#[test] #[test]
@@ -36,7 +30,7 @@ mod tests {
settings.issuer_url = "http://auth.example.com".into(); settings.issuer_url = "http://auth.example.com".into();
assert!(settings.validate().is_err()); assert!(settings.validate().is_err());
settings = enabled(); settings = enabled();
settings.redirect_url = "https://app.example.com/other".into(); settings.redirect_url = "https://app.linuxiarz.pl/other".into();
assert!(settings.validate().is_err()); assert!(settings.validate().is_err());
settings = enabled(); settings = enabled();
settings.client_secret.clear(); settings.client_secret.clear();
@@ -56,12 +50,9 @@ mod tests {
fn encrypted_cookies_are_authenticated_and_not_readable() { fn encrypted_cookies_are_authenticated_and_not_readable() {
let secret = "session-secret-at-least-32-bytes-unique"; let secret = "session-secret-at-least-32-bytes-unique";
let session = Session { let session = Session {
subject: "a-stable-id".into(), subject: "a-stable-id".into(), username: "alice".into(),
username: "alice".into(), email: Some("alice@example.com".into()), name: None,
email: Some("alice@example.com".into()), groups: vec!["member".into()], expires: now().unwrap() + 3600,
name: None,
groups: vec!["member".into()],
expires: now().unwrap() + 3600,
}; };
let encoded = seal(secret, &session).unwrap(); let encoded = seal(secret, &session).unwrap();
assert!(!encoded.contains("alice")); assert!(!encoded.contains("alice"));
@@ -82,18 +73,8 @@ mod tests {
#[test] #[test]
fn cookies_cannot_be_reused_across_oidc_clients_or_redirect_hosts() { fn cookies_cannot_be_reused_across_oidc_clients_or_redirect_hosts() {
let shared = "same-admin-configured-secret-for-multiple-applications"; let shared = "same-admin-configured-secret-for-multiple-applications";
let a = scoped_secret( let a = scoped_secret(shared, "https://auth.example.com", "app1", "https://app1.example.org/_rustproxy/oidc/callback");
shared, let b = scoped_secret(shared, "https://auth.example.com", "app2", "https://app2.example.org/_rustproxy/oidc/callback");
"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); assert_ne!(a, b);
let encrypted = seal(&a, &serde_json::json!({"sub": "alice"})).unwrap(); let encrypted = seal(&a, &serde_json::json!({"sub": "alice"})).unwrap();
assert!(open::<Value>(&b, &encrypted).is_err()); assert!(open::<Value>(&b, &encrypted).is_err());
@@ -102,10 +83,7 @@ mod tests {
#[test] #[test]
fn duplicate_cookies_cannot_override_a_valid_cookie() { fn duplicate_cookies_cannot_override_a_valid_cookie() {
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert( headers.insert(header::COOKIE, "a=1; login=ok; login=malicious".parse().unwrap());
header::COOKIE,
"a=1; login=ok; login=malicious".parse().unwrap(),
);
assert!(cookie_value(&headers, "login").is_none()); assert!(cookie_value(&headers, "login").is_none());
headers.insert(header::COOKIE, "a=1; login=ok".parse().unwrap()); headers.insert(header::COOKIE, "a=1; login=ok".parse().unwrap());
assert_eq!(cookie_value(&headers, "login"), Some("ok")); assert_eq!(cookie_value(&headers, "login"), Some("ok"));
@@ -114,28 +92,17 @@ mod tests {
#[test] #[test]
fn userinfo_must_match_signed_subject_and_does_not_trust_unverified_email() { fn userinfo_must_match_signed_subject_and_does_not_trust_unverified_email() {
let mut claims = token::Claims { let mut claims = token::Claims {
subject: "id-42".into(), subject: "id-42".into(), username: "id-42".into(), email: None,
username: "id-42".into(), name: None, groups: vec![],
email: None,
name: None,
groups: vec![],
}; };
assert!(token::merge_userinfo( assert!(token::merge_userinfo(&mut claims, &serde_json::json!({
&mut claims, "sub":"other-user", "preferred_username":"admin"
&serde_json::json!({ })).is_err());
"sub":"other-user", "preferred_username":"admin"
})
)
.is_err());
assert_eq!(claims.username, "id-42"); assert_eq!(claims.username, "id-42");
token::merge_userinfo( token::merge_userinfo(&mut claims, &serde_json::json!({
&mut claims, "sub":"id-42", "preferred_username":"alice", "email":"alice@example.com",
&serde_json::json!({ "email_verified":false, "groups":["users"]
"sub":"id-42", "preferred_username":"alice", "email":"alice@example.com", })).unwrap();
"email_verified":false, "groups":["users"]
}),
)
.unwrap();
assert_eq!(claims.username, "alice"); assert_eq!(claims.username, "alice");
assert_eq!(claims.email, None); assert_eq!(claims.email, None);
assert_eq!(claims.groups, vec!["users".to_string()]); assert_eq!(claims.groups, vec!["users".to_string()]);
@@ -145,16 +112,9 @@ mod tests {
fn id_token_signature_issuer_audience_nonce_and_expiry_are_checked() { fn id_token_signature_issuer_audience_nonce_and_expiry_are_checked() {
let rng = SystemRandom::new(); let rng = SystemRandom::new();
let pkcs8 = signature::EcdsaKeyPair::generate_pkcs8( let pkcs8 = signature::EcdsaKeyPair::generate_pkcs8(
&signature::ECDSA_P256_SHA256_FIXED_SIGNING, &signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng).unwrap();
&rng,
)
.unwrap();
let keypair = signature::EcdsaKeyPair::from_pkcs8( let keypair = signature::EcdsaKeyPair::from_pkcs8(
&signature::ECDSA_P256_SHA256_FIXED_SIGNING, &signature::ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8.as_ref(), &rng).unwrap();
pkcs8.as_ref(),
&rng,
)
.unwrap();
let public = keypair.public_key().as_ref(); let public = keypair.public_key().as_ref();
let jwks = serde_json::json!({ "keys": [{ let jwks = serde_json::json!({ "keys": [{
"kid":"key1", "kty":"EC", "use":"sig", "alg":"ES256", "crv":"P-256", "kid":"key1", "kty":"EC", "use":"sig", "alg":"ES256", "crv":"P-256",
@@ -168,75 +128,32 @@ mod tests {
}); });
let header = serde_json::json!({"alg":"ES256","kid":"key1","typ":"JWT"}); let header = serde_json::json!({"alg":"ES256","kid":"key1","typ":"JWT"});
let token = |claims: &Value| { let token = |claims: &Value| {
let data = format!( let data = format!("{}.{}",
"{}.{}",
URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()), URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()),
URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).unwrap()) URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).unwrap()));
);
let sig = keypair.sign(&rng, data.as_bytes()).unwrap(); let sig = keypair.sign(&rng, data.as_bytes()).unwrap();
format!("{}.{}", data, URL_SAFE_NO_PAD.encode(sig.as_ref())) format!("{}.{}", data, URL_SAFE_NO_PAD.encode(sig.as_ref()))
}; };
let jwt = token(&claims); let jwt = token(&claims);
let verified = token::verify_id_token( let verified = token::verify_id_token(&jwt, &jwks,
&jwt, "https://auth.example.com", "rustproxy", "session-nonce").unwrap();
&jwks,
"https://auth.example.com",
"rustproxy",
"session-nonce",
)
.unwrap();
assert_eq!(verified.subject, "id-42"); assert_eq!(verified.subject, "id-42");
assert_eq!(verified.username, "alice"); assert_eq!(verified.username, "alice");
assert!(token::verify_id_token( assert!(token::verify_id_token(&jwt, &jwks,
&jwt, "https://other.example.com", "rustproxy", "session-nonce").is_err());
&jwks, assert!(token::verify_id_token(&jwt, &jwks,
"https://other.example.com", "https://auth.example.com", "other-client", "session-nonce").is_err());
"rustproxy", assert!(token::verify_id_token(&jwt, &jwks,
"session-nonce" "https://auth.example.com", "rustproxy", "wrong-nonce").is_err());
)
.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(); let mut expired = claims.clone();
expired["exp"] = serde_json::json!(now().unwrap() - 1); expired["exp"] = serde_json::json!(now().unwrap() - 1);
assert!(token::verify_id_token( assert!(token::verify_id_token(&token(&expired), &jwks,
&token(&expired), "https://auth.example.com", "rustproxy", "session-nonce").is_err());
&jwks,
"https://auth.example.com",
"rustproxy",
"session-nonce"
)
.is_err());
let mut wrong = claims.clone(); let mut wrong = claims.clone();
wrong["sub"] = serde_json::json!("another-user"); wrong["sub"] = serde_json::json!("another-user");
let forged_parts: Vec<_> = token(&wrong).split('.').map(str::to_string).collect(); let forged_parts: Vec<_> = token(&wrong).split('.').map(str::to_string).collect();
let modified = format!( let modified = format!("{}.{}.{}", forged_parts[0], forged_parts[1], jwt.split('.').last().unwrap());
"{}.{}.{}", assert!(token::verify_id_token(&modified, &jwks,
forged_parts[0], "https://auth.example.com", "rustproxy", "session-nonce").is_err());
forged_parts[1],
jwt.split('.').last().unwrap()
);
assert!(token::verify_id_token(
&modified,
&jwks,
"https://auth.example.com",
"rustproxy",
"session-nonce"
)
.is_err());
} }
} }
+42 -158
View File
@@ -14,10 +14,7 @@ pub(super) struct Claims {
} }
fn field<'a>(value: &'a Value, name: &str) -> Result<&'a str> { fn field<'a>(value: &'a Value, name: &str) -> Result<&'a str> {
value value.get(name).and_then(Value::as_str).context("missing or invalid OIDC claim")
.get(name)
.and_then(Value::as_str)
.context("missing or invalid OIDC claim")
} }
fn key_bytes(value: &Value, key: &str) -> Result<Vec<u8>> { fn key_bytes(value: &Value, key: &str) -> Result<Vec<u8>> {
@@ -27,42 +24,22 @@ fn key_bytes(value: &Value, key: &str) -> Result<Vec<u8>> {
} }
fn validate_signature(key: &Value, alg: &str, signed: &[u8], signature_bytes: &[u8]) -> Result<()> { fn validate_signature(key: &Value, alg: &str, signed: &[u8], signature_bytes: &[u8]) -> Result<()> {
ensure!( ensure!(key.get("use").and_then(Value::as_str).is_none_or(|usage| usage == "sig"), "OIDC JWK is not a signing key");
key.get("use") ensure!(key.get("alg").and_then(Value::as_str).is_none_or(|declared| declared == alg), "OIDC JWK algorithm mismatch");
.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 { match alg {
"RS256" => { "RS256" => {
ensure!(field(key, "kty")? == "RSA", "OIDC key type mismatch"); ensure!(field(key, "kty")? == "RSA", "OIDC key type mismatch");
let n = key_bytes(key, "n")?; let n = key_bytes(key, "n")?;
let e = key_bytes(key, "e")?; let e = key_bytes(key, "e")?;
signature::RsaPublicKeyComponents { n: &n, e: &e } signature::RsaPublicKeyComponents { n: &n, e: &e }
.verify( .verify(&signature::RSA_PKCS1_2048_8192_SHA256, signed, signature_bytes)
&signature::RSA_PKCS1_2048_8192_SHA256,
signed,
signature_bytes,
)
.map_err(|_| anyhow::anyhow!("invalid OIDC signature"))?; .map_err(|_| anyhow::anyhow!("invalid OIDC signature"))?;
} }
"ES256" => { "ES256" => {
ensure!( ensure!(field(key, "kty")? == "EC" && field(key, "crv")? == "P-256", "OIDC key type mismatch");
field(key, "kty")? == "EC" && field(key, "crv")? == "P-256",
"OIDC key type mismatch"
);
let x = key_bytes(key, "x")?; let x = key_bytes(key, "x")?;
let y = key_bytes(key, "y")?; let y = key_bytes(key, "y")?;
ensure!( ensure!(x.len() == 32 && y.len() == 32, "invalid OIDC curve coordinates");
x.len() == 32 && y.len() == 32,
"invalid OIDC curve coordinates"
);
let mut public = Vec::with_capacity(65); let mut public = Vec::with_capacity(65);
public.push(4); public.push(4);
public.extend_from_slice(&x); public.extend_from_slice(&x);
@@ -92,172 +69,79 @@ pub(super) fn verify_id_token(
let header: Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(head)?)?; 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 claims: Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload)?)?;
let alg = field(&header, "alg")?; let alg = field(&header, "alg")?;
ensure!( ensure!(matches!(alg, "RS256" | "ES256"), "OIDC JWT algorithm not allowed");
matches!(alg, "RS256" | "ES256"),
"OIDC JWT algorithm not allowed"
);
let kid = header.get("kid").and_then(Value::as_str); let kid = header.get("kid").and_then(Value::as_str);
let keys = jwks let keys = jwks.get("keys").and_then(Value::as_array).context("invalid OIDC JWKS")?;
.get("keys")
.and_then(Value::as_array)
.context("invalid OIDC JWKS")?;
ensure!(keys.len() <= 32, "too many OIDC signing keys"); ensure!(keys.len() <= 32, "too many OIDC signing keys");
let candidates: Vec<_> = keys let candidates: Vec<_> = keys.iter().filter(|key| {
.iter() kid.map_or(true, |id| key.get("kid").and_then(Value::as_str) == Some(id))
.filter(|key| { && key.get("alg").and_then(Value::as_str).is_none_or(|configured| configured == alg)
kid.map_or(true, |id| { && key.get("use").and_then(Value::as_str).is_none_or(|configured| configured == "sig")
key.get("kid").and_then(Value::as_str) == Some(id) }).collect();
}) && key ensure!(candidates.len() == 1, "OIDC signing key missing or ambiguous");
.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}"); let signed = format!("{head}.{payload}");
validate_signature( validate_signature(candidates[0], alg, signed.as_bytes(), &URL_SAFE_NO_PAD.decode(signature)?)?;
candidates[0], ensure!(field(&claims, "iss")? == expected_issuer, "OIDC issuer mismatch");
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 audience = claims.get("aud").context("missing OIDC audience")?;
let audiences: Vec<&str> = match audience { let audiences: Vec<&str> = match audience {
Value::String(value) => vec![value], Value::String(value) => vec![value],
Value::Array(values) => values Value::Array(values) => values.iter().map(|value| value.as_str().context("invalid OIDC audience")).collect::<Result<_>>()?,
.iter()
.map(|value| value.as_str().context("invalid OIDC audience"))
.collect::<Result<_>>()?,
_ => bail!("invalid OIDC audience"), _ => bail!("invalid OIDC audience"),
}; };
ensure!(audiences.contains(&client_id), "OIDC audience mismatch"); ensure!(audiences.contains(&client_id), "OIDC audience mismatch");
if audiences.len() > 1 { if audiences.len() > 1 {
ensure!( ensure!(field(&claims, "azp")? == client_id, "OIDC authorized party mismatch");
field(&claims, "azp")? == client_id,
"OIDC authorized party mismatch"
);
} else if let Some(azp) = claims.get("azp").and_then(Value::as_str) { } else if let Some(azp) = claims.get("azp").and_then(Value::as_str) {
ensure!(azp == client_id, "OIDC authorized party mismatch"); ensure!(azp == client_id, "OIDC authorized party mismatch");
} }
ensure!( ensure!(field(&claims, "nonce")? == expected_nonce, "OIDC nonce mismatch");
field(&claims, "nonce")? == expected_nonce,
"OIDC nonce mismatch"
);
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let exp = claims let exp = claims.get("exp").and_then(Value::as_u64).context("missing OIDC expiration")?;
.get("exp")
.and_then(Value::as_u64)
.context("missing OIDC expiration")?;
ensure!(exp > now, "OIDC ID token expired"); ensure!(exp > now, "OIDC ID token expired");
let issued = claims let issued = claims.get("iat").and_then(Value::as_u64).context("missing OIDC issuance time")?;
.get("iat") ensure!(issued <= now.saturating_add(60), "OIDC token issued in the future");
.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") { if let Some(nbf) = claims.get("nbf") {
ensure!( ensure!(nbf.as_u64().is_some_and(|start| start <= now.saturating_add(60)), "OIDC token is not yet valid");
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(); let subject = field(&claims, "sub")?.to_string();
ensure!( ensure!(!subject.is_empty() && subject.len() <= 256, "invalid OIDC subject");
!subject.is_empty() && subject.len() <= 256, let username = claims.get("preferred_username").and_then(Value::as_str)
"invalid OIDC subject"
);
let username = claims
.get("preferred_username")
.and_then(Value::as_str)
.filter(|name| !name.is_empty() && name.len() <= 256) .filter(|name| !name.is_empty() && name.len() <= 256)
.unwrap_or(&subject) .unwrap_or(&subject).to_string();
.to_string(); let optional = |key| claims.get(key).and_then(Value::as_str)
let optional = |key| { .filter(|value| !value.is_empty() && value.len() <= 256).map(str::to_string);
claims let groups = claims.get("groups").and_then(Value::as_array)
.get(key) .map(|items| items.iter().filter_map(Value::as_str).filter(|s| !s.is_empty() && s.len() <= 128).take(32).map(str::to_string).collect())
.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(); .unwrap_or_default();
let email = if claims.get("email_verified").and_then(Value::as_bool) == Some(false) { let email = if claims.get("email_verified").and_then(Value::as_bool) == Some(false) {
None None
} else { } else {
optional("email") optional("email")
}; };
Ok(Claims { Ok(Claims { subject, username, email, name: optional("name"), groups })
subject,
username,
email,
name: optional("name"),
groups,
})
} }
pub(super) fn merge_userinfo(claims: &mut Claims, value: &Value) -> Result<()> { pub(super) fn merge_userinfo(claims: &mut Claims, value: &Value) -> Result<()> {
ensure!( ensure!(field(value, "sub")? == claims.subject, "OIDC UserInfo subject mismatch");
field(value, "sub")? == claims.subject,
"OIDC UserInfo subject mismatch"
);
if claims.username == claims.subject { if claims.username == claims.subject {
if let Some(username) = value if let Some(username) = value.get("preferred_username").and_then(Value::as_str)
.get("preferred_username") .filter(|name| !name.is_empty() && name.len() <= 256) {
.and_then(Value::as_str)
.filter(|name| !name.is_empty() && name.len() <= 256)
{
claims.username = username.to_string(); claims.username = username.to_string();
} }
} }
if claims.name.is_none() { if claims.name.is_none() {
claims.name = value claims.name = value.get("name").and_then(Value::as_str)
.get("name") .filter(|name| !name.is_empty() && name.len() <= 256).map(str::to_string);
.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) 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)
claims.email = value .filter(|name| !name.is_empty() && name.len() <= 256).map(str::to_string);
.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 claims.groups.is_empty() {
if let Some(groups) = value.get("groups").and_then(Value::as_array) { if let Some(groups) = value.get("groups").and_then(Value::as_array) {
claims.groups = groups claims.groups = groups.iter().filter_map(Value::as_str)
.iter() .filter(|name| !name.is_empty() && name.len() <= 128).take(32).map(str::to_string).collect();
.filter_map(Value::as_str)
.filter(|name| !name.is_empty() && name.len() <= 128)
.take(32)
.map(str::to_string)
.collect();
} }
} }
Ok(()) Ok(())
+2 -8
View File
@@ -254,14 +254,8 @@ impl Vhost {
.validate()?; .validate()?;
if self.oidc.enabled { if self.oidc.enabled {
let redirect = url::Url::parse(&self.oidc.redirect_url)?; let redirect = url::Url::parse(&self.oidc.redirect_url)?;
ensure!( ensure!(redirect.host_str().is_some_and(|host| self.frontend.hosts.iter().any(|name| name == host)),
redirect.host_str().is_some_and(|host| self "OIDC callback host must be an exact Vhost hostname");
.frontend
.hosts
.iter()
.any(|name| name == host)),
"OIDC callback host must be an exact Vhost hostname"
);
} }
self.pages.validate()?; self.pages.validate()?;
self.transform self.transform
+54
View File
@@ -0,0 +1,54 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const editor = fs.readFileSync(path.join(__dirname, "../src/admin/web/vhosts/editor.js"), "utf8");
const begin = editor.indexOf("const authMethodsByProvider = {");
const end = editor.indexOf("function load(value = defaults()", begin);
assert.ok(begin >= 0 && end > begin);
const values = new Map();
const context = vm.createContext({
str: key => values.get(key) || "",
yes: () => false,
num: () => 5,
nullable: () => null,
lines: () => []
});
const selection = vm.runInContext(editor.slice(begin, end) +
"\n({ authMethodsByProvider, inferredAuthentication, selectedAuthentication, readForwardProvider })", context);
const legacy = [
[{ basic_auth: { enabled: true } }, "basic", "basic"],
[{ tinyauth: { enabled: true } }, "tinyauth", "forward_auth"],
[{ authelia: { enabled: true } }, "authelia", "forward_auth"],
[{ authentik: { enabled: true } }, "authentik", "forward_auth"],
[{ oidc: { enabled: true } }, "custom", "oidc"],
[{}, "none", "none"]
];
for (const [vhost, provider, method] of legacy) {
const actual = selection.inferredAuthentication(vhost);
assert.equal(actual.provider, provider);
assert.equal(actual.method, method);
}
for (const provider of ["tinyauth", "authelia", "authentik", "pocket_id", "custom"]) {
const selected = { provider, method: "oidc" };
const actual = selection.inferredAuthentication({ authentication: selected, oidc: { enabled: true } });
assert.equal(actual.provider, provider);
assert.equal(actual.method, "oidc");
values.set("auth-provider", provider);
values.set("auth-method", "oidc");
assert.equal(selection.selectedAuthentication().method, "oidc");
for (const forward of ["authelia", "authentik"]) assert.equal(selection.readForwardProvider(forward).enabled, false);
}
for (const provider of ["authelia", "authentik"]) {
values.set("auth-provider", provider);
values.set("auth-method", "forward_auth");
assert.equal(selection.readForwardProvider(provider).enabled, true);
}
values.set("auth-provider", "none");
values.set("auth-method", "");
assert.equal(selection.selectedAuthentication().method, "none");
assert.deepEqual(Array.from(selection.authMethodsByProvider.pocket_id), ["oidc"]);
assert.deepEqual(Array.from(selection.authMethodsByProvider.basic), ["basic"]);
assert.deepEqual(Array.from(selection.authMethodsByProvider.authelia), ["forward_auth", "oidc"]);
console.log("Vhost provider/method UI selection checks: OK");