v0.34.16
This commit is contained in:
1 parent
f27fbf6a30
commit
2c03fcf248
18 files changed
+421
-911
No files matched your search
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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)]
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
+149
-503
@@ -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"),
|
aead::Aad::from("rustproxy-oidc-v1"), &mut bytes,
|
||||||
&mut bytes,
|
).map_err(|_| anyhow::anyhow!("OIDC cookie encryption failure"))?;
|
||||||
)
|
|
||||||
.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"),
|
aead::Aad::from("rustproxy-oidc-v1"), &mut bytes[12..],
|
||||||
&mut bytes[12..],
|
).map_err(|_| anyhow::anyhow!("invalid OIDC cookie authentication"))?;
|
||||||
)
|
|
||||||
.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!("{}/.well-known/openid-configuration", self.issuer.trim_end_matches('/')))?;
|
||||||
let discovery_url = self.url_for_endpoint(&format!(
|
|
||||||
"{}/.well-known/openid-configuration",
|
|
||||||
self.issuer.trim_end_matches('/')
|
|
||||||
))?;
|
|
||||||
let metadata = self.get_json(&discovery_url).await?;
|
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()
|
|
||||||
.filter(|name| {
|
|
||||||
let name = name.as_str();
|
let name = name.as_str();
|
||||||
name.starts_with("remote-")
|
name.starts_with("remote-") || name.starts_with("x-authentik-") || name.starts_with("x-auth-request-")
|
||||||
|| name.starts_with("x-authentik-")
|
|| matches!(name, "x-forwarded-user" | "x-forwarded-email" | "x-remote-user" | "x-remote-email" | "x-user" | "x-email")
|
||||||
|| name.starts_with("x-auth-request-")
|
}).cloned().collect();
|
||||||
|| matches!(
|
for name in names { headers.remove(name); }
|
||||||
name,
|
|
||||||
"x-forwarded-user"
|
|
||||||
| "x-forwarded-email"
|
|
||||||
| "x-remote-user"
|
|
||||||
| "x-remote-email"
|
|
||||||
| "x-user"
|
|
||||||
| "x-email"
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
for name in names {
|
|
||||||
headers.remove(name);
|
|
||||||
}
|
}
|
||||||
}
|
fn authorize<'a>(&'a self, incoming: &'a HeaderMap, method: &'a Method,
|
||||||
fn authorize<'a>(
|
context: &'a forwarding::Context, shutdown: &'a CancellationToken,
|
||||||
&'a self,
|
cookies: &'a CookieSink) -> auth_provider::Authorization<'a> {
|
||||||
incoming: &'a HeaderMap,
|
|
||||||
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,
|
||||||
|
|||||||
+34
-117
@@ -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,
|
|
||||||
&serde_json::json!({
|
|
||||||
"sub":"other-user", "preferred_username":"admin"
|
"sub":"other-user", "preferred_username":"admin"
|
||||||
})
|
})).is_err());
|
||||||
)
|
|
||||||
.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,
|
|
||||||
&serde_json::json!({
|
|
||||||
"sub":"id-42", "preferred_username":"alice", "email":"alice@example.com",
|
"sub":"id-42", "preferred_username":"alice", "email":"alice@example.com",
|
||||||
"email_verified":false, "groups":["users"]
|
"email_verified":false, "groups":["users"]
|
||||||
}),
|
})).unwrap();
|
||||||
)
|
|
||||||
.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
@@ -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(())
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
Reference in new issue
Block a user