diff --git a/CHANGELOG.md b/CHANGELOG.md index 1209e57..495173e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Release history focused on **features, settings, default behavior, compatibility ## 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. - **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. diff --git a/SHA256SUMS b/SHA256SUMS index fec5b81..4a0da7a 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -2,7 +2,7 @@ 4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example 9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore 92250158a2241c61d4c61aa840025fd5dce9aec3d406d7f199a8673c7ff0cebb AGENTS.md -d5655bd4146fda0ce345a8e7cf011f662275728d3d48e41561be6a7c4b7a454d CHANGELOG.md +8a7f5b8690547b6f2aeeeed884ee7025d5201bb03695b04813b3ecdc80d47103 CHANGELOG.md 3deb7f5702d36446f069cc9c5927bdcbfdc306887bec6f45fbc9507295b6c5c9 Cargo.lock 024fe1c84a50255b7b32ea12110285c60b6386daba02a57843324baee5ae94fb Cargo.toml 25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile @@ -29,11 +29,11 @@ e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/n 5220d669171ec3ad65f50e0bc8de5178dadaa4904def8e1a4f61002aa9e9a55b docker-agent/server.go 26af69f0dca733c7659bfdb7c9e07f2521aaaa83913735d952a33d1bda85b95c docs/ACCESS.md 147755fb33e9e91098a4d2dfbe6df8b0d92d602e002d568e64a0f815641be2c2 docs/ACME_PROVIDERS.md -b359ca6b82fcb74f2ebdc59a68270d915c781586ea100e9598d4989815f21b2d docs/API.md +a7e307a2d6bff1ee3d6fbdef14e873d95acb691ac8f4730cebab8f25335f6425 docs/API.md 2362ab34020d4e0dcd70e280e9539ef7764b63212856ee08a3a55906c48f6421 docs/API_ACCESS.md 728b15b9825f547f34c4121219784798dd046fbbdd72ece8906d0cd77e8efc64 docs/BACKUP.md 3219aae15fd2e10157343ecdad8b175bd9735f39bccb31a5cb811553c13084dd docs/CACHE.md -b29bceb308327db9a45faba3f144c2fd7e057c833d5cafdcc216cf82aedd7854 docs/CONFIGURATION.md +3cdf36e6345f4bbbae846d975fbdbe7a87afea93563d24c06e3733eae6809417 docs/CONFIGURATION.md 80bcc666736b10f6202386d0342e8d6feeba858d77aea310bf3ec6de01c2bfb3 docs/CONTAINER.md dc670d912a6a946160c2f8528c9bb710ec38ebe406dcb7dcaf18fb2653f667b6 docs/CUSTOM_LOCATIONS.md e350c5081085b79e579d1500fa07feee94a709adaf6dfde7b3d9af124a78d184 docs/DASHBOARD.md @@ -110,7 +110,7 @@ e4d15aa6aa864d04b9b30aca33826b833632af02ca643f8b23e219a5ea1298dd scripts/deploy a641bd35400c56f3b68b0a92e97b602d06090f03972ca8f01fa744e90dd97403 scripts/deploy/upgrade.py 2cc658faaa610b21b48942c59b955525539ad4503fd60f2a49c7e626a22faaff scripts/migration/migrate_npm.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 3d6a0503afc25beab79ddc86864bcbb15248ded7368d33aa64321b8cdff59933 scripts/resolve_dependencies_0312.sh 20124173452b4514bad8850c1637569a9a191882478451e549bd16b432605fc1 scripts/runtime/acme_provider.py @@ -160,13 +160,13 @@ b3c3c2d33c150eb42ce210c6074e5eff6a8af9391118012e413bc9323d3ae50c src/admin/pref 62ecbae5dee00aeeca3eb806e2605372471dfab971cde8f66f11e711df8fcea1 src/admin/public/mod.rs 7a00e4ab943f74d2b1bdcbfab7f9cd3f584e9f59536b4aca6c4c31f5f5508d12 src/admin/public/tests.rs 6930b4f4b45257772b321b20a9becdf9bc4610274852948ba38c2d0cdf02dfaf src/admin/refresh_preferences.rs -cc3aa1b094b0d869d141754ac2877817ec40a362acfd7fc3167ea3c9f1ff1a18 src/admin/router.rs +c8454f3092145c5fefdc8d93951ae46f192a6eb8139dbe288284b3a1c803b192 src/admin/router.rs e135431cb078ddb483165581123c64af8f00ea67acb3a395ec3e53c85d0d0d45 src/admin/session.rs 7aeb784a3ef58795c8fe26467bb4e1a00e61420911f798a584ab09c12f73a754 src/admin/status.rs 2d5b4c28fd0bd48b06890fa8ec44a2296de948c1f88eb2909f4b495710d4e13e src/admin/status_live.rs 8315c856cd50f1072593ecb30c274d42d82f078b5fb93e7c68a8857cd81b0841 src/admin/status_system.rs cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs -d5af049839db73e4d44f2b4455d072c6fb167ac2bd87b3f4aab0ef1d1a2e1209 src/admin/tests.rs +1d3ea6803763827251317070329c8eaf86491d4a6adb8a90406f32009e4a9b1c src/admin/tests.rs c18c369124368f58524652565102817ed69fd25b222b0c5e737ef7632b45dcfb src/admin/web/acl-picker.js 48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js 86abf50f39862d821cd2e43ca425671aa85289272915132c5a9a5dd1fc4182e2 src/admin/web/api-tokens.js @@ -323,13 +323,13 @@ d87f99c48c05ededb0586626d3179391257f54363516119f1567fa0590546485 src/cache/varn 8de82d9188c9c337690c6ec4afdad9fff20651c358e34602a93d8bb8c3d2489a src/compression/tests.rs e695079333ebdc2a6e7b3337cfabef171b3a0a01da0597b77feff70d417f833e src/config.rs 9f6272bd6d1f403cf6324b12472fd5c7d911a387139b57105ba1819e48ea0a54 src/config/backend.rs -f4df024acdb24be450822e58108af23db3dc82bf1f77531cd16fe340fc01062c src/config/compile.rs +53409e9f92815cef254e138213b32f8d05a01ebc62e176064dc7463c86ce2d71 src/config/compile.rs fbea01fea1213d98aea812c1b78d5db289e09200b98ae514d742235cd3fa70e1 src/config/internal_routing.rs -101253b4c9275e38dc7e1088051d6cd55ae7616213eb1c02e3e1c5ffc6fea4a0 src/config/model.rs +c21961205a7f58ca25dea553ce56590e3d8f4011bb657577421e773e130e5527 src/config/model.rs 75839458ddbc7480ec4492a95eeb67b617ba7d62f7b67e7a767a3453b74dbcbc src/config/route_policy_tests.rs 3c0ede1417e83720b010efa670fe8494e3ad0e9138065d725cbb9c969de6c559 src/config/routing.rs dd9a6ad8f58e0cdd1ae74f3f972fee0ac14383cd8b8f518403b9a36c169f35bd src/config/snapshot.rs -9d2fdb3f4c9d4117a6766e56ebe6e1e34687d40100728de98f47e9fce3fe3aa5 src/config/tests.rs +8676b307dfcca462d9bdd934e3f1432b8b903f89542d502ae20f8a7b24742557 src/config/tests.rs f35de0ee1516ece6bbfd2b2a7db7d1e0f5048b0ec47cf2a3765dffb799abfd93 src/config/transport_tests.rs e0f46d885ed81ce306a110560cdd2c217dadde054986d89fcec835ebf6939d65 src/db.rs c290d0cb02dfa5d2c60ecabac1cfb20c8884e3d14cca1944906c6d8e677f1784 src/db/api_tokens.rs @@ -377,7 +377,7 @@ a9e453c94fc37e37d9f1f461494fffd5f88e13e15f6cd4158464dee4fafb14bd src/logging/te 5a98d0e249338e73aa57ae2e352dc16db2f79c2d042c1544ce1c2022da76a31c src/main/proxy_runtime.rs b240033715323e7cb7b9bcaf92edd481f7089b4da6bab25005ca6733da8756dc src/main/shutdown/mod.rs 0a6578a0f4023b9f8d73043bf942dc5e096ef9afb8c0d2f131c14f65d08ac01d src/main/shutdown/tests.rs -ca01c97cc52a50797306193a546e4379fe3ce9401b0c936c756b881180315ab0 src/main/tasks.rs +ff491043180085800b786b77e6bd3c400b60051012482918440cd5dc6493d1e7 src/main/tasks.rs a719c51aebcba0f16f8810c37618201954e79d6806da085ee6c5e4697a05b521 src/metrics.rs 9c7ed78345abc33d260f8d5cd52339bf8de748c08b53eaa2bdea8a32a0ea2a20 src/metrics/global.rs 31154052757c2bb975b8ce8cad67f3fad7b5132b615a73d5b0d7bf1b5311722b src/metrics/rate_sampling.rs diff --git a/docs/API.md b/docs/API.md index 67199c5..b196cf1 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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. - 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. - 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. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 3d0a681..7cb809c 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -6,6 +6,8 @@ Most settings are managed in the authenticated panel. Save changes after validat ## 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 `/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 diff --git a/scripts/migration/migrate_npm_core.py b/scripts/migration/migrate_npm_core.py index 67a2a9f..9c34075 100755 --- a/scripts/migration/migrate_npm_core.py +++ b/scripts/migration/migrate_npm_core.py @@ -29,7 +29,7 @@ import urllib.error import urllib.parse import urllib.request -MAX_CONFIG_BYTES = 262_144 +MAX_CONFIG_BYTES = 2_097_152 class MigrationError(RuntimeError): diff --git a/src/admin/router.rs b/src/admin/router.rs index 384cdeb..a53151f 100644 --- a/src/admin/router.rs +++ b/src/admin/router.rs @@ -285,7 +285,7 @@ pub fn router(state: Arc) -> Router { .route("/api/metrics/summary", get(metrics_summary)) .fallback(|| async { reject(StatusCode::NOT_FOUND, "not_found") }) .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(), observe_admin)) diff --git a/src/admin/tests.rs b/src/admin/tests.rs index 620d1ca..f54fd82 100644 --- a/src/admin/tests.rs +++ b/src/admin/tests.rs @@ -832,6 +832,50 @@ mod tests { 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] async fn section_updates_are_revisioned_and_do_not_require_full_config_payloads() { let (router, store, _dir, secret, csrf) = fixture().await; diff --git a/src/auth_provider/choice.rs b/src/auth_provider/choice.rs index 77ff708..321a8c8 100644 --- a/src/auth_provider/choice.rs +++ b/src/auth_provider/choice.rs @@ -58,10 +58,7 @@ pub struct Choice { } impl Choice { pub fn validate(self) -> Result<()> { - ensure!( - self.provider.supports(self.method), - "unsupported authentication provider and integration method combination" - ); + ensure!(self.provider.supports(self.method), "unsupported authentication provider and integration method combination"); Ok(()) } } @@ -72,11 +69,7 @@ mod tests { #[test] fn supported_methods_are_explicit_and_reject_invalid_pairs() { - for provider in [ - IdentityProvider::Tinyauth, - IdentityProvider::Authelia, - IdentityProvider::Authentik, - ] { + for provider in [IdentityProvider::Tinyauth, IdentityProvider::Authelia, IdentityProvider::Authentik] { assert!(provider.supports(IntegrationMethod::ForwardAuth)); assert!(provider.supports(IntegrationMethod::Oidc)); } @@ -91,25 +84,11 @@ mod tests { #[test] fn selection_serializes_as_independent_provider_and_method() { - let selection = Choice { - provider: IdentityProvider::Authelia, - method: IntegrationMethod::Oidc, - }; + let selection = Choice { provider: IdentityProvider::Authelia, method: IntegrationMethod::Oidc }; let json = serde_json::to_value(selection).unwrap(); - assert_eq!( - json, - serde_json::json!({"provider":"authelia","method":"oidc"}) - ); + assert_eq!(json, serde_json::json!({"provider":"authelia","method":"oidc"})); assert_eq!(serde_json::from_value::(json).unwrap(), selection); - assert!(serde_json::from_value::( - serde_json::json!({"provider":"pocket_id","method":"forward_auth"}) - ) - .unwrap() - .validate() - .is_err()); - assert!(serde_json::from_value::( - serde_json::json!({"provider":"unknown","method":"oidc"}) - ) - .is_err()); + assert!(serde_json::from_value::(serde_json::json!({"provider":"pocket_id","method":"forward_auth"})).unwrap().validate().is_err()); + assert!(serde_json::from_value::(serde_json::json!({"provider":"unknown","method":"oidc"})).is_err()); } } diff --git a/src/auth_provider/mod.rs b/src/auth_provider/mod.rs index 0b875e6..b46ccd4 100644 --- a/src/auth_provider/mod.rs +++ b/src/auth_provider/mod.rs @@ -130,9 +130,7 @@ impl Selection<'_> { if let Some(choice) = self.choice { choice.validate()?; let matches_configuration = match choice.method { - IntegrationMethod::Basic => { - self.basic_enabled && choice.provider == IdentityProvider::Basic - } + IntegrationMethod::Basic => self.basic_enabled && choice.provider == IdentityProvider::Basic, IntegrationMethod::ForwardAuth => match choice.provider { IdentityProvider::Tinyauth => self.tinyauth.enabled, IdentityProvider::Authelia => self.authelia.enabled, @@ -141,10 +139,7 @@ impl Selection<'_> { }, IntegrationMethod::Oidc => self.oidc.enabled, }; - ensure!( - matches_configuration, - "authentication selection does not match the enabled settings" - ); + ensure!(matches_configuration, "authentication selection does not match the enabled settings"); } Ok(()) } @@ -164,12 +159,9 @@ impl Selection<'_> { return Ok(self.authentik.compile(policy, bindings)?.map(Compiled::new)); } if self.oidc.enabled { - let (id, label) = self.choice.map_or(("oidc", "OIDC"), |choice| { - (choice.provider.id(), choice.provider.display_name()) - }); - return Ok(self - .oidc - .compile(policy, bindings)? + let (id, label) = self.choice.map_or(("oidc", "OIDC"), + |choice| (choice.provider.id(), choice.provider.display_name())); + return Ok(self.oidc.compile(policy, bindings)? .map(|provider| Compiled::new(provider.with_provider(id, label)))); } Ok(None) @@ -258,69 +250,47 @@ mod tests { tinyauth.url = "http://127.0.0.1:3000".into(); tinyauth.login_url = "https://auth.example.net".into(); let oidc = oidc::Settings { - enabled: true, - issuer_url: "https://auth.test.com".into(), + enabled: true, issuer_url: "https://auth.gruszczynski.cc".into(), backend_url: "http://127.0.0.1:9091".into(), - client_id: "test-client".into(), - client_secret: "test-secret".into(), - redirect_url: "https://app.example.com/_rustproxy/oidc/callback".into(), + client_id: "test-client".into(), client_secret: "test-secret".into(), + redirect_url: "https://app.linuxiarz.pl/_rustproxy/oidc/callback".into(), session_secret: "long-session-secret-at-least-32-characters".into(), ..oidc::Settings::default() }; assert!(Selection { choice: None, - basic_enabled: false, - tinyauth: &tinyauth, + basic_enabled: false, tinyauth: &tinyauth, authelia: &authelia::Settings::default(), - authentik: &authentik::Settings::default(), - oidc: &oidc, - } - .validate() - .is_err()); + authentik: &authentik::Settings::default(), oidc: &oidc, + }.validate().is_err()); } #[test] fn oidc_identity_provider_is_independent_of_integration_method() { let mut oidc = oidc::Settings::default(); oidc.enabled = true; - oidc.issuer_url = "https://auth.test.com".into(); + oidc.issuer_url = "https://auth.gruszczynski.cc".into(); oidc.client_id = "rustproxy-test".into(); oidc.client_secret = "test-client-secret".into(); oidc.session_secret = "a-long-random-value-for-testing-oidc-sessions".into(); - oidc.redirect_url = "https://app.example.com/_rustproxy/oidc/callback".into(); - for provider in [ - IdentityProvider::Tinyauth, - IdentityProvider::Authelia, - IdentityProvider::Authentik, - IdentityProvider::PocketId, - IdentityProvider::Custom, - ] { + oidc.redirect_url = "https://app.linuxiarz.pl/_rustproxy/oidc/callback".into(); + for provider in [IdentityProvider::Tinyauth, IdentityProvider::Authelia, + IdentityProvider::Authentik, IdentityProvider::PocketId, + IdentityProvider::Custom] { assert!(Selection { - choice: Some(Choice { - provider, - method: IntegrationMethod::Oidc - }), + choice: Some(Choice { provider, method: IntegrationMethod::Oidc }), basic_enabled: false, tinyauth: &tinyauth::Settings::default(), authelia: &authelia::Settings::default(), authentik: &authentik::Settings::default(), oidc: &oidc, - } - .validate() - .is_ok()); + }.validate().is_ok()); } assert!(Selection { - choice: Some(Choice { - provider: IdentityProvider::Authelia, - method: IntegrationMethod::ForwardAuth - }), - basic_enabled: false, - tinyauth: &tinyauth::Settings::default(), - authelia: &authelia::Settings::default(), - authentik: &authentik::Settings::default(), + choice: Some(Choice { provider: IdentityProvider::Authelia, method: IntegrationMethod::ForwardAuth }), + basic_enabled: false, tinyauth: &tinyauth::Settings::default(), + authelia: &authelia::Settings::default(), authentik: &authentik::Settings::default(), oidc: &oidc, - } - .validate() - .is_err()); + }.validate().is_err()); } #[test] @@ -333,32 +303,16 @@ mod tests { let authentik = authentik::Settings::default(); let oidc = oidc::Settings::default(); let selection = |choice| Selection { - choice, - basic_enabled: false, - tinyauth: &tinyauth, - authelia: &authelia, - authentik: &authentik, - oidc: &oidc, + choice, basic_enabled: false, tinyauth: &tinyauth, + authelia: &authelia, authentik: &authentik, oidc: &oidc, }; assert!(selection(None).validate().is_ok()); - assert!(selection(Some(Choice { - provider: IdentityProvider::Tinyauth, - method: IntegrationMethod::ForwardAuth - })) - .validate() - .is_ok()); - assert!(selection(Some(Choice { - provider: IdentityProvider::Authelia, - method: IntegrationMethod::ForwardAuth - })) - .validate() - .is_err()); - assert!(selection(Some(Choice { - provider: IdentityProvider::Tinyauth, - method: IntegrationMethod::Oidc - })) - .validate() - .is_err()); + assert!(selection(Some(Choice { provider: IdentityProvider::Tinyauth, + method: IntegrationMethod::ForwardAuth })).validate().is_ok()); + assert!(selection(Some(Choice { provider: IdentityProvider::Authelia, + method: IntegrationMethod::ForwardAuth })).validate().is_err()); + assert!(selection(Some(Choice { provider: IdentityProvider::Tinyauth, + method: IntegrationMethod::Oidc })).validate().is_err()); } #[derive(Debug)] diff --git a/src/config/compile.rs b/src/config/compile.rs index 5e84f08..d8c1ac5 100644 --- a/src/config/compile.rs +++ b/src/config/compile.rs @@ -208,9 +208,10 @@ pub fn compile_with_active( <= MAX_ROUTES, "too many rules across all redirect hosts (max 256)" ); + let config_bytes = serde_json::to_vec(&config)?.len(); ensure!( - serde_json::to_vec(&config)?.len() <= MAX_CONFIG_BYTES, - "configuration too large" + config_bytes <= MAX_CONFIG_BYTES, + "configuration too large ({config_bytes} bytes, maximum {MAX_CONFIG_BYTES} bytes)" ); let listeners = crate::vhost::listener_plan(&config, &desired_policy)?; crate::stream::validate( diff --git a/src/config/model.rs b/src/config/model.rs index 0c30a69..1212c8f 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -13,7 +13,7 @@ use std::{ }; 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_ROUTES: usize = 256; diff --git a/src/config/tests.rs b/src/config/tests.rs index 734ac55..fa4b624 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -9,6 +9,42 @@ mod tests { 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] fn route_handles_reuse_snapshot_ownership_without_cloning_compiled_routes() { let snapshot = @@ -80,17 +116,14 @@ mod tests { method: crate::auth_provider::IntegrationMethod::Oidc, }); 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_secret = "client-secret".into(); host.oidc.session_secret = "a-unique-testing-session-secret-32-bytes".into(); host.oidc.redirect_url = "https://localhost/_rustproxy/oidc/callback".into(); assert!(host.validate().is_ok()); let encoded = serde_json::to_value(&host).unwrap(); - assert_eq!( - encoded["authentication"], - serde_json::json!({"provider":"authelia","method":"oidc"}) - ); + assert_eq!(encoded["authentication"], serde_json::json!({"provider":"authelia","method":"oidc"})); let restored: crate::vhost::Vhost = serde_json::from_value(encoded.clone()).unwrap(); assert_eq!(restored.authentication, host.authentication); assert!(restored.validate().is_ok()); diff --git a/src/main/tasks.rs b/src/main/tasks.rs index 50d02ce..72a6f1a 100644 --- a/src/main/tasks.rs +++ b/src/main/tasks.rs @@ -398,7 +398,7 @@ async fn api( async fn read_config_file(file: PathBuf) -> Result { ensure!( 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 file = std::fs::File::open(file)?; @@ -407,7 +407,7 @@ async fn read_config_file(file: PathBuf) -> Result { .read_to_end(&mut bytes)?; anyhow::ensure!( bytes.len() <= config::MAX_CONFIG_BYTES, - "config file exceeds 256 KiB" + "config file exceeds size limit" ); Ok::<_, anyhow::Error>(bytes) }) diff --git a/src/oidc/mod.rs b/src/oidc/mod.rs index 9fe06e8..37e210e 100644 --- a/src/oidc/mod.rs +++ b/src/oidc/mod.rs @@ -5,24 +5,13 @@ use crate::{ }; use anyhow::{ensure, Context as _, Result}; use axum::{body::Body, response::Response}; -use base64::{ - engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, - Engine as _, -}; +use base64::{engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, Engine as _}; use http::{header, HeaderMap, HeaderValue, Method, Request, StatusCode, Uri}; use http_body_util::{BodyExt, Limited}; -use ring::{ - aead, digest, hmac, - rand::{SecureRandom, SystemRandom}, -}; +use ring::{aead, digest, hmac, rand::{SecureRandom, SystemRandom}}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde_json::Value; -use std::{ - fmt, - net::SocketAddr, - sync::Arc, - time::{Duration, Instant, SystemTime, UNIX_EPOCH}, -}; +use std::{fmt, net::SocketAddr, sync::Arc, time::{Duration, Instant, SystemTime, UNIX_EPOCH}}; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; use url::{form_urlencoded, Url}; @@ -53,183 +42,87 @@ pub struct Settings { impl Default for Settings { fn default() -> Self { Self { - enabled: false, - issuer_url: String::new(), - backend_url: String::new(), - client_id: String::new(), - client_secret: String::new(), - scopes: "openid profile email".into(), - session_secret: String::new(), - redirect_url: String::new(), - allow_private: false, - address_allowlist: Vec::new(), - tls_ca_pem: None, - connect_timeout_seconds: 5, - timeout_seconds: 10, - session_ttl_seconds: 3600, + enabled: false, issuer_url: String::new(), backend_url: String::new(), client_id: String::new(), + client_secret: String::new(), scopes: "openid profile email".into(), session_secret: String::new(), redirect_url: String::new(), + allow_private: false, address_allowlist: Vec::new(), tls_ca_pem: None, + connect_timeout_seconds: 5, timeout_seconds: 10, session_ttl_seconds: 3600, } } } fn https_url(raw: &str) -> Result { - ensure!( - !raw.is_empty() && raw.len() <= 2048, - "OIDC URL must be 1..2048 bytes" - ); + ensure!(!raw.is_empty() && raw.len() <= 2048, "OIDC URL must be 1..2048 bytes"); let url = Url::parse(raw)?; - ensure!( - url.scheme() == "https" && url.host_str().is_some(), - "OIDC requires HTTPS URLs" - ); - ensure!( - url.username().is_empty() - && url.password().is_none() - && url.query().is_none() - && url.fragment().is_none(), - "OIDC URL cannot contain credentials, queries or fragments" - ); + ensure!(url.scheme() == "https" && url.host_str().is_some(), "OIDC requires HTTPS URLs"); + ensure!(url.username().is_empty() && url.password().is_none() && url.query().is_none() && url.fragment().is_none(), "OIDC URL cannot contain credentials, queries or fragments"); Ok(url) } impl Settings { pub fn validate(&self) -> Result<()> { - ensure!( - (1..=30).contains(&self.connect_timeout_seconds) - && (1..=60).contains(&self.timeout_seconds) - && self.connect_timeout_seconds <= self.timeout_seconds, - "OIDC timeouts must be 1..30s connect <= 1..60s total" - ); - ensure!( - (60..=86400).contains(&self.session_ttl_seconds), - "OIDC session lifetime must be 60..86400 seconds" - ); + ensure!((1..=30).contains(&self.connect_timeout_seconds) + && (1..=60).contains(&self.timeout_seconds) + && self.connect_timeout_seconds <= self.timeout_seconds, + "OIDC timeouts must be 1..30s connect <= 1..60s total"); + ensure!((60..=86400).contains(&self.session_ttl_seconds), "OIDC session lifetime must be 60..86400 seconds"); if self.enabled { let issuer = https_url(&self.issuer_url)?; ensure!(!issuer.path().contains(".."), "OIDC issuer path is invalid"); if !self.backend_url.is_empty() { - let internal = - crate::forward_auth::http_url(&self.backend_url, "OIDC internal backend")?; - ensure!( - internal.path() == "/" && internal.query().is_none(), - "OIDC internal backend URL must be an origin without a path or query" - ); + let internal = crate::forward_auth::http_url(&self.backend_url, "OIDC internal backend")?; + ensure!(internal.path() == "/" && internal.query().is_none(), + "OIDC internal backend URL must be an origin without a path or query"); } - ensure!( - !self.client_id.is_empty() - && self.client_id.len() <= 256 - && self - .client_id - .bytes() - .all(|c| c.is_ascii_alphanumeric() || b"-._~".contains(&c)), - "OIDC client ID must contain 1..256 URL-safe characters" - ); - ensure!( - !self.client_secret.is_empty() && self.client_secret.len() <= 2048, - "OIDC client secret required" - ); - ensure!( - self.session_secret.len() >= 32 && self.session_secret.len() <= 4096, - "OIDC session secret must be at least 32 bytes" - ); - ensure!( - self.scopes.len() <= 1024 - && self - .scopes - .bytes() - .all(|c| c.is_ascii_alphanumeric() || b" -_:.".contains(&c)), - "OIDC scopes cannot contain control characters" - ); + ensure!(!self.client_id.is_empty() && self.client_id.len() <= 256 + && self.client_id.bytes().all(|c| c.is_ascii_alphanumeric() || b"-._~".contains(&c)), + "OIDC client ID must contain 1..256 URL-safe characters"); + ensure!(!self.client_secret.is_empty() && self.client_secret.len() <= 2048, "OIDC client secret required"); + ensure!(self.session_secret.len() >= 32 && self.session_secret.len() <= 4096, "OIDC session secret must be at least 32 bytes"); + ensure!(self.scopes.len() <= 1024 && self.scopes.bytes().all(|c| c.is_ascii_alphanumeric() || b" -_:.".contains(&c)), + "OIDC scopes cannot contain control characters"); let scopes: Vec<_> = self.scopes.split_whitespace().collect(); - ensure!( - scopes.len() <= 16 - && scopes.contains(&"openid") - && scopes.iter().all(|item| item.len() <= 64 - && item - .bytes() - .all(|c| c.is_ascii_alphanumeric() || b"-_:.".contains(&c))), - "OIDC scopes must contain openid and valid space-separated names" - ); + ensure!(scopes.len() <= 16 && scopes.contains(&"openid") + && scopes.iter().all(|item| item.len() <= 64 && item.bytes().all(|c| c.is_ascii_alphanumeric() || b"-_:.".contains(&c))), + "OIDC scopes must contain openid and valid space-separated names"); let redirect = https_url(&self.redirect_url)?; - ensure!( - redirect.path() == CALLBACK, - "OIDC redirect URL must end with {CALLBACK}" - ); + ensure!(redirect.path() == CALLBACK, "OIDC redirect URL must end with {CALLBACK}"); } Ok(()) } - pub fn compile( - &self, - policy: &ValidationPolicy, - bindings: &[SocketAddr], - ) -> Result> { + pub fn compile(&self, policy: &ValidationPolicy, bindings: &[SocketAddr]) -> Result> { self.validate()?; - if !self.enabled { - return Ok(None); - } + if !self.enabled { return Ok(None); } let issuer = https_url(&self.issuer_url)?; let redirect = https_url(&self.redirect_url)?; let upstream_origin = if self.backend_url.is_empty() { issuer.origin().ascii_serialization() } else { - crate::forward_auth::http_url(&self.backend_url, "OIDC internal backend")? - .origin() - .ascii_serialization() + crate::forward_auth::http_url(&self.backend_url, "OIDC internal backend")?.origin().ascii_serialization() }; let public_host: Uri = issuer.as_str().parse()?; - let host_header = HeaderValue::from_str( - public_host - .authority() - .context("OIDC issuer authority missing")? - .as_str(), - )?; + let host_header = HeaderValue::from_str(public_host.authority().context("OIDC issuer authority missing")?.as_str())?; let definition = Backend { - id: "oidc".into(), - url: upstream_origin.clone(), - allow_private: self.allow_private, - accept_local_listener_risk: false, - preserve_host: false, - protocol: UpstreamProtocol::Http1, + id: "oidc".into(), url: upstream_origin.clone(), + allow_private: self.allow_private, accept_local_listener_risk: false, + preserve_host: false, protocol: UpstreamProtocol::Http1, address_allowlist: self.address_allowlist.clone(), - tls_ca_pem: self.tls_ca_pem.clone(), - tls_server_name: None, - tls_verify: true, + tls_ca_pem: self.tls_ca_pem.clone(), tls_server_name: None, tls_verify: true, }; let keepalive = crate::balance::Keepalive::default(); - let mut backend = - crate::config::compile_backend(&definition, policy, bindings, &keepalive)?; + let mut backend = crate::config::compile_backend(&definition, policy, bindings, &keepalive)?; backend.client = crate::upstream::client_with_timeout( - &definition, - backend.address_policy.clone(), - false, - &keepalive, + &definition, backend.address_policy.clone(), false, &keepalive, Duration::from_secs(self.connect_timeout_seconds), )?; - let fingerprint = digest::digest( - &digest::SHA256, - format!("{}:{}", self.client_id, self.redirect_url).as_bytes(), - ); - let suffix = fingerprint.as_ref()[..8] - .iter() - .map(|c| format!("{c:02x}")) - .collect::>() - .join(""); + let fingerprint = digest::digest(&digest::SHA256, + format!("{}:{}", self.client_id, self.redirect_url).as_bytes()); + let suffix = fingerprint.as_ref()[..8].iter().map(|c| format!("{c:02x}")).collect::>().join(""); Ok(Some(Compiled { - provider_id: "oidc", - provider_label: "OIDC", - issuer: self.issuer_url.clone(), - client_id: self.client_id.clone(), - scopes: self.scopes.clone(), + provider_id: "oidc", provider_label: "OIDC", + issuer: self.issuer_url.clone(), client_id: self.client_id.clone(), scopes: self.scopes.clone(), client_secret: self.client_secret.clone(), - session_secret: scoped_secret( - &self.session_secret, - &self.issuer_url, - &self.client_id, - &self.redirect_url, - ), - redirect, - backend: Arc::new(backend), - cache: Mutex::new(None), - upstream_origin: Url::parse(&upstream_origin)?, - host_header, + session_secret: scoped_secret(&self.session_secret, &self.issuer_url, &self.client_id, &self.redirect_url), + redirect, backend: Arc::new(backend), cache: Mutex::new(None), + upstream_origin: Url::parse(&upstream_origin)?, host_header, internal_override: !self.backend_url.is_empty(), timeout: Duration::from_secs(self.timeout_seconds), session_ttl: self.session_ttl_seconds, @@ -279,10 +172,8 @@ impl Compiled { } impl fmt::Debug for Compiled { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("OidcProvider") - .field("issuer", &self.issuer) - .field("redirect", &self.redirect) - .finish_non_exhaustive() + f.debug_struct("OidcProvider").field("issuer", &self.issuer) + .field("redirect", &self.redirect).finish_non_exhaustive() } } #[derive(Serialize, Deserialize)] @@ -308,9 +199,7 @@ fn now() -> Result { fn random_b64(bytes: usize) -> Result { ensure!(bytes <= 128, "random input too long"); let mut random = vec![0u8; bytes]; - SystemRandom::new() - .fill(&mut random) - .map_err(|_| anyhow::anyhow!("random source unavailable"))?; + SystemRandom::new().fill(&mut random).map_err(|_| anyhow::anyhow!("random source unavailable"))?; Ok(URL_SAFE_NO_PAD.encode(random)) } fn scoped_secret(secret: &str, issuer: &str, client_id: &str, redirect: &str) -> String { @@ -320,25 +209,17 @@ fn scoped_secret(secret: &str, issuer: &str, client_id: &str, redirect: &str) -> } fn crypt_key(secret: &str) -> Result { let hash = digest::digest(&digest::SHA256, secret.as_bytes()); - Ok(aead::LessSafeKey::new( - aead::UnboundKey::new(&aead::AES_256_GCM, hash.as_ref()) - .map_err(|_| anyhow::anyhow!("OIDC cipher initialization failed"))?, - )) + Ok(aead::LessSafeKey::new(aead::UnboundKey::new(&aead::AES_256_GCM, hash.as_ref()).map_err(|_| anyhow::anyhow!("OIDC cipher initialization failed"))?)) } fn seal(secret: &str, value: &T) -> Result { let mut nonce_bytes = [0u8; 12]; - SystemRandom::new() - .fill(&mut nonce_bytes) - .map_err(|_| anyhow::anyhow!("random source unavailable"))?; + SystemRandom::new().fill(&mut nonce_bytes).map_err(|_| anyhow::anyhow!("random source unavailable"))?; let mut bytes = serde_json::to_vec(value)?; ensure!(bytes.len() <= 3072, "OIDC cookie contents too large"); - crypt_key(secret)? - .seal_in_place_append_tag( - aead::Nonce::assume_unique_for_key(nonce_bytes), - aead::Aad::from("rustproxy-oidc-v1"), - &mut bytes, - ) - .map_err(|_| anyhow::anyhow!("OIDC cookie encryption failure"))?; + crypt_key(secret)?.seal_in_place_append_tag( + aead::Nonce::assume_unique_for_key(nonce_bytes), + aead::Aad::from("rustproxy-oidc-v1"), &mut bytes, + ).map_err(|_| anyhow::anyhow!("OIDC cookie encryption failure"))?; let mut data = nonce_bytes.to_vec(); data.extend_from_slice(&bytes); let encoded = URL_SAFE_NO_PAD.encode(data); @@ -351,13 +232,10 @@ fn open(secret: &str, encoded: &str) -> Result { ensure!(bytes.len() > 28, "invalid OIDC cookie"); let mut nonce = [0u8; 12]; nonce.copy_from_slice(&bytes[..12]); - let plaintext = crypt_key(secret)? - .open_in_place( - aead::Nonce::assume_unique_for_key(nonce), - aead::Aad::from("rustproxy-oidc-v1"), - &mut bytes[12..], - ) - .map_err(|_| anyhow::anyhow!("invalid OIDC cookie authentication"))?; + let plaintext = crypt_key(secret)?.open_in_place( + aead::Nonce::assume_unique_for_key(nonce), + aead::Aad::from("rustproxy-oidc-v1"), &mut bytes[12..], + ).map_err(|_| anyhow::anyhow!("invalid OIDC cookie authentication"))?; Ok(serde_json::from_slice(plaintext)?) } fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { @@ -366,9 +244,7 @@ fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { for entry in line.to_str().ok()?.split(';') { if let Some((key, value)) = entry.trim().split_once('=') { if key == name { - if selected.is_some() { - return None; - } + if selected.is_some() { return None; } selected = Some(value); } } @@ -380,27 +256,19 @@ fn cookie(name: &str, value: &str, age: u64) -> Result { Ok(format!("{name}={value}; Path=/; Max-Age={age}; HttpOnly; Secure; SameSite=Lax").parse()?) } fn clear_cookie(name: &str) -> HeaderValue { - format!("{name}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax") - .parse() - .expect("static valid cookie name") + format!("{name}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax").parse().expect("static valid cookie name") } fn redirect(location: &str, status: StatusCode) -> Response { let mut response = auth_provider::deny(status); match location.parse() { - Ok(value) => { - response.headers_mut().insert(header::LOCATION, value); - } - Err(_) => { - return auth_provider::deny(StatusCode::BAD_GATEWAY); - } + Ok(value) => { response.headers_mut().insert(header::LOCATION, value); } + Err(_) => { return auth_provider::deny(StatusCode::BAD_GATEWAY); } } response } fn sanitize_header(headers: &mut HeaderMap, name: &'static str, value: &str) { if value.len() <= 2048 { - if let Ok(value) = value.parse() { - headers.insert(name, value); - } + if let Ok(value) = value.parse() { headers.insert(name, value); } } } 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-issuer", issuer); sanitize_header(&mut headers, "remote-user", &session.username); - if let Some(email) = &session.email { - sanitize_header(&mut headers, "remote-email", email); - } - if let Some(name) = &session.name { - sanitize_header(&mut headers, "remote-name", name); - } - if !session.groups.is_empty() { - sanitize_header(&mut headers, "remote-groups", &session.groups.join(",")); - } + if let Some(email) = &session.email { sanitize_header(&mut headers, "remote-email", email); } + if let Some(name) = &session.name { sanitize_header(&mut headers, "remote-name", name); } + if !session.groups.is_empty() { sanitize_header(&mut headers, "remote-groups", &session.groups.join(",")); } Identity { headers } } impl Compiled { fn url_for_endpoint(&self, raw: &str) -> Result { let url = https_url(raw)?; - ensure!( - url.origin() == https_url(&self.issuer)?.origin(), - "OIDC endpoints must use the configured issuer origin" - ); + ensure!(url.origin() == https_url(&self.issuer)?.origin(), "OIDC endpoints must use the configured issuer origin"); Ok(url) } fn wire_uri(&self, public: &Url) -> Result { - ensure!( - public.origin() == https_url(&self.issuer)?.origin(), - "OIDC request not on issuer origin" - ); + ensure!(public.origin() == https_url(&self.issuer)?.origin(), "OIDC request not on issuer origin"); let mut wire = self.upstream_origin.clone(); wire.set_path(public.path()); wire.set_query(public.query()); @@ -445,145 +301,64 @@ impl Compiled { } } async fn get_json(&self, url: &Url) -> Result { - let mut request = Request::builder() - .method(Method::GET) - .uri(self.wire_uri(url)?) + let mut request = Request::builder().method(Method::GET).uri(self.wire_uri(url)?) .body(Body::empty())?; self.wire_headers(request.headers_mut()); - request - .headers_mut() - .insert(header::ACCEPT, HeaderValue::from_static("application/json")); + request.headers_mut().insert(header::ACCEPT, HeaderValue::from_static("application/json")); let response = self.backend.client.request(request).await?; - ensure!( - response.status() == StatusCode::OK, - "OIDC endpoint returned an error" - ); - let body = Limited::new(response.into_body(), 65536) - .collect() - .await - .map_err(|error| anyhow::anyhow!("invalid OIDC endpoint response: {error}"))? - .to_bytes(); + ensure!(response.status() == StatusCode::OK, "OIDC endpoint returned an error"); + let body = Limited::new(response.into_body(), 65536).collect().await + .map_err(|error| anyhow::anyhow!("invalid OIDC endpoint response: {error}"))?.to_bytes(); Ok(serde_json::from_slice(&body)?) } async fn discovery(&self) -> Result { let mut guard = self.cache.lock().await; if let Some(saved) = guard.as_ref() { - if saved.until > Instant::now() { - return Ok(saved.discovery.clone()); - } + if saved.until > Instant::now() { return Ok(saved.discovery.clone()); } } - let discovery_url = self.url_for_endpoint(&format!( - "{}/.well-known/openid-configuration", - self.issuer.trim_end_matches('/') - ))?; + let discovery_url = self.url_for_endpoint(&format!("{}/.well-known/openid-configuration", self.issuer.trim_end_matches('/')))?; let metadata = self.get_json(&discovery_url).await?; - ensure!( - metadata.get("issuer").and_then(Value::as_str) == Some(self.issuer.as_str()), - "OIDC discovery issuer mismatch" - ); - let authorization = self.url_for_endpoint( - metadata - .get("authorization_endpoint") - .and_then(Value::as_str) - .context("missing OIDC authorization endpoint")?, - )?; - let token = self.url_for_endpoint( - metadata - .get("token_endpoint") - .and_then(Value::as_str) - .context("missing OIDC token endpoint")?, - )?; - let userinfo = metadata - .get("userinfo_endpoint") - .and_then(Value::as_str) - .map(|url| self.url_for_endpoint(url)) - .transpose()?; - let jwks_url = self.url_for_endpoint( - metadata - .get("jwks_uri") - .and_then(Value::as_str) - .context("missing OIDC JWKS URI")?, - )?; + ensure!(metadata.get("issuer").and_then(Value::as_str) == Some(self.issuer.as_str()), "OIDC discovery issuer mismatch"); + let authorization = self.url_for_endpoint(metadata.get("authorization_endpoint").and_then(Value::as_str).context("missing OIDC authorization endpoint")?)?; + let token = self.url_for_endpoint(metadata.get("token_endpoint").and_then(Value::as_str).context("missing OIDC token endpoint")?)?; + let userinfo = metadata.get("userinfo_endpoint").and_then(Value::as_str) + .map(|url| self.url_for_endpoint(url)).transpose()?; + let jwks_url = self.url_for_endpoint(metadata.get("jwks_uri").and_then(Value::as_str).context("missing OIDC JWKS URI")?)?; let jwks = self.get_json(&jwks_url).await?; - ensure!( - jwks.get("keys").and_then(Value::as_array).is_some(), - "invalid OIDC signing keys" - ); - let discovery = Discovery { - authorization, - token, - userinfo, - jwks, - }; - *guard = Some(Cached { - until: Instant::now() + Duration::from_secs(300), - discovery: discovery.clone(), - }); + ensure!(jwks.get("keys").and_then(Value::as_array).is_some(), "invalid OIDC signing keys"); + let discovery = Discovery { authorization, token, userinfo, jwks }; + *guard = Some(Cached { until: Instant::now() + Duration::from_secs(300), discovery: discovery.clone() }); Ok(discovery) } fn is_redirect_host(&self, context: &forwarding::Context) -> bool { - context.scheme == "https" - && Url::parse(&context.original_url) - .is_ok_and(|url| url.origin() == self.redirect.origin()) + context.scheme == "https" && Url::parse(&context.original_url) + .is_ok_and(|url| url.origin() == self.redirect.origin()) } - async fn initiate( - &self, - context: &forwarding::Context, - cookies: &CookieSink, - ) -> Result { - ensure!( - self.is_redirect_host(context), - "OIDC request origin does not match callback" - ); + async fn initiate(&self, context: &forwarding::Context, cookies: &CookieSink) -> Result { + ensure!(self.is_redirect_host(context), "OIDC request origin does not match callback"); let destination = Url::parse(&context.original_url)?; let mut return_to = destination.path().to_owned(); - if let Some(query) = destination.query() { - return_to.push('?'); - return_to.push_str(query); - } - ensure!( - return_to.len() <= 1800 && return_to.starts_with('/') && !return_to.starts_with("//"), - "invalid OIDC return URL" - ); + if let Some(query) = destination.query() { return_to.push('?'); return_to.push_str(query); } + ensure!(return_to.len() <= 1800 && return_to.starts_with('/') && !return_to.starts_with("//"), "invalid OIDC return URL"); let transaction = Pending { - state: random_b64(32)?, - nonce: random_b64(32)?, - verifier: random_b64(32)?, - return_to, - issued: now()?, + state: random_b64(32)?, nonce: random_b64(32)?, verifier: random_b64(32)?, + return_to, issued: now()?, }; let mut authorization = self.discovery().await?.authorization; let challenge = digest::digest(&digest::SHA256, transaction.verifier.as_bytes()); - authorization - .query_pairs_mut() - .append_pair("response_type", "code") + authorization.query_pairs_mut().append_pair("response_type", "code") .append_pair("client_id", &self.client_id) .append_pair("redirect_uri", self.redirect.as_str()) .append_pair("scope", &self.scopes) .append_pair("state", &transaction.state) .append_pair("nonce", &transaction.nonce) .append_pair("code_challenge_method", "S256") - .append_pair( - "code_challenge", - &URL_SAFE_NO_PAD.encode(challenge.as_ref()), - ); + .append_pair("code_challenge", &URL_SAFE_NO_PAD.encode(challenge.as_ref())); let mut response = redirect(authorization.as_str(), StatusCode::FOUND); let mut set_cookie = HeaderMap::new(); - set_cookie.append( - header::SET_COOKIE, - cookie( - &self.state_cookie, - &seal(&self.session_secret, &transaction)?, - 300, - )?, - ); - ensure!( - cookies.collect(&set_cookie), - "OIDC transaction cookie too large" - ); - response - .headers_mut() - .insert("referrer-policy", HeaderValue::from_static("no-referrer")); + set_cookie.append(header::SET_COOKIE, cookie(&self.state_cookie, &seal(&self.session_secret, &transaction)?, 300)?); + ensure!(cookies.collect(&set_cookie), "OIDC transaction cookie too large"); + response.headers_mut().insert("referrer-policy", HeaderValue::from_static("no-referrer")); Ok(response) } async fn exchange(&self, code: &str, pending: &Pending) -> Result { @@ -594,89 +369,38 @@ impl Compiled { .append_pair("redirect_uri", self.redirect.as_str()) .append_pair("code_verifier", &pending.verifier) .finish(); - let id_encoded: String = - form_urlencoded::byte_serialize(self.client_id.as_bytes()).collect(); - let secret_encoded: String = - form_urlencoded::byte_serialize(self.client_secret.as_bytes()).collect(); + let id_encoded: String = form_urlencoded::byte_serialize(self.client_id.as_bytes()).collect(); + let secret_encoded: String = form_urlencoded::byte_serialize(self.client_secret.as_bytes()).collect(); let authorization = STANDARD.encode(format!("{id_encoded}:{secret_encoded}")); - let mut request = Request::builder() - .method(Method::POST) - .uri(self.wire_uri(&discovery.token)?) + let mut request = Request::builder().method(Method::POST).uri(self.wire_uri(&discovery.token)?) .body(Body::from(body))?; self.wire_headers(request.headers_mut()); - request.headers_mut().insert( - header::AUTHORIZATION, - format!("Basic {authorization}").parse()?, - ); - request - .headers_mut() - .insert(header::ACCEPT, HeaderValue::from_static("application/json")); - request.headers_mut().insert( - header::CONTENT_TYPE, - HeaderValue::from_static("application/x-www-form-urlencoded"), - ); + request.headers_mut().insert(header::AUTHORIZATION, format!("Basic {authorization}").parse()?); + request.headers_mut().insert(header::ACCEPT, HeaderValue::from_static("application/json")); + request.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("application/x-www-form-urlencoded")); let response = self.backend.client.request(request).await?; - ensure!( - response.status() == StatusCode::OK, - "OIDC token endpoint returned an error" - ); - let body = Limited::new(response.into_body(), 65536) - .collect() - .await - .map_err(|error| anyhow::anyhow!("invalid OIDC token response: {error}"))? - .to_bytes(); + ensure!(response.status() == StatusCode::OK, "OIDC token endpoint returned an error"); + let body = Limited::new(response.into_body(), 65536).collect().await + .map_err(|error| anyhow::anyhow!("invalid OIDC token response: {error}"))?.to_bytes(); let value: Value = serde_json::from_slice(&body)?; - let id_token = value - .get("id_token") - .and_then(Value::as_str) - .context("missing OIDC ID token")?; - let mut claims = token::verify_id_token( - id_token, - &discovery.jwks, - &self.issuer, - &self.client_id, - &pending.nonce, - )?; - if let (Some(userinfo_url), Some(access_token)) = ( - discovery.userinfo.as_ref(), - value.get("access_token").and_then(Value::as_str), - ) { - ensure!( - access_token.len() <= 8192 && !access_token.is_empty(), - "invalid OIDC access token" - ); - let mut request = Request::builder() - .method(Method::GET) - .uri(self.wire_uri(userinfo_url)?) + let id_token = value.get("id_token").and_then(Value::as_str).context("missing OIDC ID token")?; + let mut claims = token::verify_id_token(id_token, &discovery.jwks, &self.issuer, &self.client_id, &pending.nonce)?; + if let (Some(userinfo_url), Some(access_token)) = (discovery.userinfo.as_ref(), value.get("access_token").and_then(Value::as_str)) { + ensure!(access_token.len() <= 8192 && !access_token.is_empty(), "invalid OIDC access token"); + let mut request = Request::builder().method(Method::GET).uri(self.wire_uri(userinfo_url)?) .body(Body::empty())?; self.wire_headers(request.headers_mut()); - request.headers_mut().insert( - header::AUTHORIZATION, - format!("Bearer {access_token}").parse()?, - ); - request - .headers_mut() - .insert(header::ACCEPT, HeaderValue::from_static("application/json")); + request.headers_mut().insert(header::AUTHORIZATION, format!("Bearer {access_token}").parse()?); + request.headers_mut().insert(header::ACCEPT, HeaderValue::from_static("application/json")); let response = self.backend.client.request(request).await?; - ensure!( - response.status() == StatusCode::OK, - "OIDC UserInfo endpoint returned an error" - ); - let data = Limited::new(response.into_body(), 65536) - .collect() - .await - .map_err(|error| anyhow::anyhow!("invalid OIDC UserInfo response: {error}"))? - .to_bytes(); + ensure!(response.status() == StatusCode::OK, "OIDC UserInfo endpoint returned an error"); + let data = Limited::new(response.into_body(), 65536).collect().await + .map_err(|error| anyhow::anyhow!("invalid OIDC UserInfo response: {error}"))?.to_bytes(); token::merge_userinfo(&mut claims, &serde_json::from_slice::(&data)?)?; } Ok(claims) } - async fn callback( - &self, - request: Request, - context: &forwarding::Context, - shutdown: &CancellationToken, - ) -> Response { + async fn callback(&self, request: Request, context: &forwarding::Context, shutdown: &CancellationToken) -> Response { if request.method() != Method::GET || !self.is_redirect_host(context) { return auth_provider::deny(StatusCode::BAD_REQUEST); } @@ -684,20 +408,14 @@ impl Compiled { let mut valid_state = None; let mut error = false; if let Some(query) = request.uri().query() { - if query.len() > 8192 { - return auth_provider::deny(StatusCode::BAD_REQUEST); - } + if query.len() > 8192 { return auth_provider::deny(StatusCode::BAD_REQUEST); } for (key, value) in form_urlencoded::parse(query.as_bytes()) { match key.as_ref() { - "code" if valid_code.is_none() && value.len() <= 4096 => { - valid_code = Some(value.into_owned()) - } - "state" if valid_state.is_none() && value.len() <= 256 => { - valid_state = Some(value.into_owned()) - } + "code" if valid_code.is_none() && value.len() <= 4096 => valid_code = Some(value.into_owned()), + "state" if valid_state.is_none() && value.len() <= 256 => valid_state = Some(value.into_owned()), "error" => error = true, "code" | "state" => error = true, - _ => {} + _ => {}, } } } @@ -708,18 +426,11 @@ impl Compiled { Ok(value) => value, Err(_) => return auth_provider::deny(StatusCode::UNAUTHORIZED), }; - let current = match now() { - Ok(now) => now, - Err(_) => return auth_provider::deny(StatusCode::BAD_GATEWAY), - }; - if error - || current < pending.issued - || current.saturating_sub(pending.issued) > 300 + let current = match now() { Ok(now) => now, Err(_) => return auth_provider::deny(StatusCode::BAD_GATEWAY) }; + if error || current < pending.issued || current.saturating_sub(pending.issued) > 300 || valid_state.as_deref() != Some(pending.state.as_str()) || valid_code.as_deref().is_none_or(str::is_empty) - { - return auth_provider::deny(StatusCode::UNAUTHORIZED); - } + { return auth_provider::deny(StatusCode::UNAUTHORIZED); } let code = valid_code.expect("validated authorization code"); let result = tokio::select! { _ = shutdown.cancelled() => return auth_provider::deny(StatusCode::SERVICE_UNAVAILABLE), @@ -729,90 +440,44 @@ impl Compiled { Ok(Ok(claims)) => claims, Ok(Err(error)) => { tracing::warn!(provider = self.provider_id, %error, "OIDC callback verification failed"); - return auth_provider::gateway_failure( - self.provider_label, - GatewayFailure::Verification, - ); - } - Err(_) => { - return auth_provider::gateway_failure(self.provider_label, GatewayFailure::Timeout) + return auth_provider::gateway_failure(self.provider_label, GatewayFailure::Verification); } + Err(_) => return auth_provider::gateway_failure(self.provider_label, GatewayFailure::Timeout), }; let session = Session { - subject: claims.subject, - username: claims.username, - email: claims.email, - name: claims.name, - groups: claims.groups, + subject: claims.subject, username: claims.username, email: claims.email, + name: claims.name, groups: claims.groups, expires: current.saturating_add(self.session_ttl), }; let value = match seal(&self.session_secret, &session) { Ok(value) => value, - Err(_) => { - return auth_provider::gateway_failure( - self.provider_label, - GatewayFailure::Verification, - ) - } + Err(_) => return auth_provider::gateway_failure(self.provider_label, GatewayFailure::Verification), }; let mut response = redirect(&pending.return_to, StatusCode::SEE_OTHER); - response - .headers_mut() - .append(header::SET_COOKIE, clear_cookie(&self.state_cookie)); - response - .headers_mut() - .insert("referrer-policy", HeaderValue::from_static("no-referrer")); + response.headers_mut().append(header::SET_COOKIE, clear_cookie(&self.state_cookie)); + response.headers_mut().insert("referrer-policy", HeaderValue::from_static("no-referrer")); if let Ok(cookie) = cookie(&self.session_cookie, &value, self.session_ttl) { response.headers_mut().append(header::SET_COOKIE, cookie); - } else { - return auth_provider::deny(StatusCode::BAD_GATEWAY); - } + } else { return auth_provider::deny(StatusCode::BAD_GATEWAY); } response } } impl auth_provider::Provider for Compiled { - fn id(&self) -> &'static str { - self.provider_id - } - fn method(&self) -> auth_provider::IntegrationMethod { - auth_provider::IntegrationMethod::Oidc - } + fn id(&self) -> &'static str { self.provider_id } + fn method(&self) -> auth_provider::IntegrationMethod { auth_provider::IntegrationMethod::Oidc } fn strip_identity(&self, headers: &mut HeaderMap) { - let names: Vec<_> = headers - .keys() - .filter(|name| { - let name = name.as_str(); - name.starts_with("remote-") - || name.starts_with("x-authentik-") - || name.starts_with("x-auth-request-") - || matches!( - name, - "x-forwarded-user" - | "x-forwarded-email" - | "x-remote-user" - | "x-remote-email" - | "x-user" - | "x-email" - ) - }) - .cloned() - .collect(); - for name in names { - headers.remove(name); - } + let names: Vec<_> = headers.keys().filter(|name| { + let name = name.as_str(); + name.starts_with("remote-") || name.starts_with("x-authentik-") || name.starts_with("x-auth-request-") + || matches!(name, "x-forwarded-user" | "x-forwarded-email" | "x-remote-user" | "x-remote-email" | "x-user" | "x-email") + }).cloned().collect(); + for name in names { headers.remove(name); } } - fn authorize<'a>( - &'a self, - incoming: &'a HeaderMap, - method: &'a Method, - context: &'a forwarding::Context, - shutdown: &'a CancellationToken, - cookies: &'a CookieSink, - ) -> auth_provider::Authorization<'a> { + fn authorize<'a>(&'a self, incoming: &'a HeaderMap, method: &'a Method, + context: &'a forwarding::Context, shutdown: &'a CancellationToken, + cookies: &'a CookieSink) -> auth_provider::Authorization<'a> { Box::pin(async move { - if !self.is_redirect_host(context) { - return Err(auth_provider::deny(StatusCode::FORBIDDEN)); - } + if !self.is_redirect_host(context) { return Err(auth_provider::deny(StatusCode::FORBIDDEN)); } if let Some(encoded) = cookie_value(incoming, &self.session_cookie) { if let Ok(session) = open::(&self.session_secret, encoded) { if now().is_ok_and(|current| session.expires > current) { @@ -831,44 +496,25 @@ impl auth_provider::Provider for Compiled { Ok(Ok(response)) => response, Ok(Err(error)) => { tracing::warn!(provider = self.provider_id, %error, "OIDC login initiation failed"); - auth_provider::gateway_failure( - self.provider_label, - GatewayFailure::Verification, - ) - } - Err(_) => { - auth_provider::gateway_failure(self.provider_label, GatewayFailure::Timeout) + auth_provider::gateway_failure(self.provider_label, GatewayFailure::Verification) } + Err(_) => auth_provider::gateway_failure(self.provider_label, GatewayFailure::Timeout), }) }) } - fn reserved_path(&self, path: &str) -> bool { - matches!(path, CALLBACK | LOGOUT) - } - fn serve_reserved<'a>( - &'a self, - request: Request, - context: &'a forwarding::Context, - shutdown: &'a CancellationToken, - ) -> Option> { + fn reserved_path(&self, path: &str) -> bool { matches!(path, CALLBACK | LOGOUT) } + fn serve_reserved<'a>(&'a self, request: Request, context: &'a forwarding::Context, + shutdown: &'a CancellationToken) -> Option> { match request.uri().path() { - CALLBACK => Some(Box::pin(async move { - self.callback(request, context, shutdown).await - })), + CALLBACK => Some(Box::pin(async move { self.callback(request, context, shutdown).await })), LOGOUT => Some(Box::pin(async move { if request.method() != Method::GET || !self.is_redirect_host(context) { return auth_provider::deny(StatusCode::BAD_REQUEST); } let mut response = redirect("/", StatusCode::SEE_OTHER); - response - .headers_mut() - .append(header::SET_COOKIE, clear_cookie(&self.session_cookie)); - response - .headers_mut() - .append(header::SET_COOKIE, clear_cookie(&self.state_cookie)); - response - .headers_mut() - .insert("referrer-policy", HeaderValue::from_static("no-referrer")); + response.headers_mut().append(header::SET_COOKIE, clear_cookie(&self.session_cookie)); + response.headers_mut().append(header::SET_COOKIE, clear_cookie(&self.state_cookie)); + response.headers_mut().insert("referrer-policy", HeaderValue::from_static("no-referrer")); response })), _ => None, diff --git a/src/oidc/tests.rs b/src/oidc/tests.rs index de3df65..b3cf99e 100644 --- a/src/oidc/tests.rs +++ b/src/oidc/tests.rs @@ -6,11 +6,11 @@ mod tests { fn enabled() -> Settings { Settings { enabled: true, - issuer_url: "https://auth.test.com".into(), + issuer_url: "https://auth.gruszczynski.cc".into(), client_id: "rustproxy-app".into(), client_secret: "super-secret-client".into(), session_secret: "an-uncorrelated-high-entropy-session-secret-value".into(), - redirect_url: "https://app.example.com/_rustproxy/oidc/callback".into(), + redirect_url: "https://app.linuxiarz.pl/_rustproxy/oidc/callback".into(), ..Settings::default() } } @@ -20,14 +20,8 @@ mod tests { assert!(!Settings::default().enabled); let settings = enabled(); assert!(settings.validate().is_ok()); - assert_eq!( - https_url(&settings.issuer_url).unwrap().host_str(), - Some("https://auth.test.com") - ); - assert_eq!( - https_url(&settings.redirect_url).unwrap().host_str(), - Some("app.example.com") - ); + assert_eq!(https_url(&settings.issuer_url).unwrap().host_str(), Some("auth.gruszczynski.cc")); + assert_eq!(https_url(&settings.redirect_url).unwrap().host_str(), Some("app.linuxiarz.pl")); } #[test] @@ -36,7 +30,7 @@ mod tests { settings.issuer_url = "http://auth.example.com".into(); assert!(settings.validate().is_err()); 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()); settings = enabled(); settings.client_secret.clear(); @@ -56,12 +50,9 @@ mod tests { fn encrypted_cookies_are_authenticated_and_not_readable() { let secret = "session-secret-at-least-32-bytes-unique"; let session = Session { - subject: "a-stable-id".into(), - username: "alice".into(), - email: Some("alice@example.com".into()), - name: None, - groups: vec!["member".into()], - expires: now().unwrap() + 3600, + subject: "a-stable-id".into(), username: "alice".into(), + email: Some("alice@example.com".into()), name: None, + groups: vec!["member".into()], expires: now().unwrap() + 3600, }; let encoded = seal(secret, &session).unwrap(); assert!(!encoded.contains("alice")); @@ -82,18 +73,8 @@ mod tests { #[test] fn cookies_cannot_be_reused_across_oidc_clients_or_redirect_hosts() { let shared = "same-admin-configured-secret-for-multiple-applications"; - let a = scoped_secret( - shared, - "https://auth.example.com", - "app1", - "https://app1.example.org/_rustproxy/oidc/callback", - ); - let b = scoped_secret( - shared, - "https://auth.example.com", - "app2", - "https://app2.example.org/_rustproxy/oidc/callback", - ); + let a = scoped_secret(shared, "https://auth.example.com", "app1", "https://app1.example.org/_rustproxy/oidc/callback"); + let b = scoped_secret(shared, "https://auth.example.com", "app2", "https://app2.example.org/_rustproxy/oidc/callback"); assert_ne!(a, b); let encrypted = seal(&a, &serde_json::json!({"sub": "alice"})).unwrap(); assert!(open::(&b, &encrypted).is_err()); @@ -102,10 +83,7 @@ mod tests { #[test] fn duplicate_cookies_cannot_override_a_valid_cookie() { let mut headers = HeaderMap::new(); - headers.insert( - header::COOKIE, - "a=1; login=ok; login=malicious".parse().unwrap(), - ); + headers.insert(header::COOKIE, "a=1; login=ok; login=malicious".parse().unwrap()); assert!(cookie_value(&headers, "login").is_none()); headers.insert(header::COOKIE, "a=1; login=ok".parse().unwrap()); assert_eq!(cookie_value(&headers, "login"), Some("ok")); @@ -114,28 +92,17 @@ mod tests { #[test] fn userinfo_must_match_signed_subject_and_does_not_trust_unverified_email() { let mut claims = token::Claims { - subject: "id-42".into(), - username: "id-42".into(), - email: None, - name: None, - groups: vec![], + subject: "id-42".into(), username: "id-42".into(), email: None, + name: None, groups: vec![], }; - assert!(token::merge_userinfo( - &mut claims, - &serde_json::json!({ - "sub":"other-user", "preferred_username":"admin" - }) - ) - .is_err()); + assert!(token::merge_userinfo(&mut claims, &serde_json::json!({ + "sub":"other-user", "preferred_username":"admin" + })).is_err()); assert_eq!(claims.username, "id-42"); - token::merge_userinfo( - &mut claims, - &serde_json::json!({ - "sub":"id-42", "preferred_username":"alice", "email":"alice@example.com", - "email_verified":false, "groups":["users"] - }), - ) - .unwrap(); + token::merge_userinfo(&mut claims, &serde_json::json!({ + "sub":"id-42", "preferred_username":"alice", "email":"alice@example.com", + "email_verified":false, "groups":["users"] + })).unwrap(); assert_eq!(claims.username, "alice"); assert_eq!(claims.email, None); assert_eq!(claims.groups, vec!["users".to_string()]); @@ -145,16 +112,9 @@ mod tests { fn id_token_signature_issuer_audience_nonce_and_expiry_are_checked() { let rng = SystemRandom::new(); let pkcs8 = signature::EcdsaKeyPair::generate_pkcs8( - &signature::ECDSA_P256_SHA256_FIXED_SIGNING, - &rng, - ) - .unwrap(); + &signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng).unwrap(); let keypair = signature::EcdsaKeyPair::from_pkcs8( - &signature::ECDSA_P256_SHA256_FIXED_SIGNING, - pkcs8.as_ref(), - &rng, - ) - .unwrap(); + &signature::ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8.as_ref(), &rng).unwrap(); let public = keypair.public_key().as_ref(); let jwks = serde_json::json!({ "keys": [{ "kid":"key1", "kty":"EC", "use":"sig", "alg":"ES256", "crv":"P-256", @@ -168,75 +128,32 @@ mod tests { }); let header = serde_json::json!({"alg":"ES256","kid":"key1","typ":"JWT"}); 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(claims).unwrap()) - ); + URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).unwrap())); let sig = keypair.sign(&rng, data.as_bytes()).unwrap(); format!("{}.{}", data, URL_SAFE_NO_PAD.encode(sig.as_ref())) }; let jwt = token(&claims); - let verified = token::verify_id_token( - &jwt, - &jwks, - "https://auth.example.com", - "rustproxy", - "session-nonce", - ) - .unwrap(); + let verified = token::verify_id_token(&jwt, &jwks, + "https://auth.example.com", "rustproxy", "session-nonce").unwrap(); assert_eq!(verified.subject, "id-42"); assert_eq!(verified.username, "alice"); - assert!(token::verify_id_token( - &jwt, - &jwks, - "https://other.example.com", - "rustproxy", - "session-nonce" - ) - .is_err()); - assert!(token::verify_id_token( - &jwt, - &jwks, - "https://auth.example.com", - "other-client", - "session-nonce" - ) - .is_err()); - assert!(token::verify_id_token( - &jwt, - &jwks, - "https://auth.example.com", - "rustproxy", - "wrong-nonce" - ) - .is_err()); + assert!(token::verify_id_token(&jwt, &jwks, + "https://other.example.com", "rustproxy", "session-nonce").is_err()); + assert!(token::verify_id_token(&jwt, &jwks, + "https://auth.example.com", "other-client", "session-nonce").is_err()); + assert!(token::verify_id_token(&jwt, &jwks, + "https://auth.example.com", "rustproxy", "wrong-nonce").is_err()); let mut expired = claims.clone(); expired["exp"] = serde_json::json!(now().unwrap() - 1); - assert!(token::verify_id_token( - &token(&expired), - &jwks, - "https://auth.example.com", - "rustproxy", - "session-nonce" - ) - .is_err()); + assert!(token::verify_id_token(&token(&expired), &jwks, + "https://auth.example.com", "rustproxy", "session-nonce").is_err()); let mut wrong = claims.clone(); wrong["sub"] = serde_json::json!("another-user"); let forged_parts: Vec<_> = token(&wrong).split('.').map(str::to_string).collect(); - let modified = format!( - "{}.{}.{}", - forged_parts[0], - forged_parts[1], - jwt.split('.').last().unwrap() - ); - assert!(token::verify_id_token( - &modified, - &jwks, - "https://auth.example.com", - "rustproxy", - "session-nonce" - ) - .is_err()); + let modified = format!("{}.{}.{}", forged_parts[0], forged_parts[1], jwt.split('.').last().unwrap()); + assert!(token::verify_id_token(&modified, &jwks, + "https://auth.example.com", "rustproxy", "session-nonce").is_err()); } } diff --git a/src/oidc/token.rs b/src/oidc/token.rs index 7edd866..34bdc7c 100644 --- a/src/oidc/token.rs +++ b/src/oidc/token.rs @@ -14,10 +14,7 @@ pub(super) struct Claims { } fn field<'a>(value: &'a Value, name: &str) -> Result<&'a str> { - value - .get(name) - .and_then(Value::as_str) - .context("missing or invalid OIDC claim") + value.get(name).and_then(Value::as_str).context("missing or invalid OIDC claim") } fn key_bytes(value: &Value, key: &str) -> Result> { @@ -27,42 +24,22 @@ fn key_bytes(value: &Value, key: &str) -> Result> { } fn validate_signature(key: &Value, alg: &str, signed: &[u8], signature_bytes: &[u8]) -> Result<()> { - ensure!( - key.get("use") - .and_then(Value::as_str) - .is_none_or(|usage| usage == "sig"), - "OIDC JWK is not a signing key" - ); - ensure!( - key.get("alg") - .and_then(Value::as_str) - .is_none_or(|declared| declared == alg), - "OIDC JWK algorithm mismatch" - ); + ensure!(key.get("use").and_then(Value::as_str).is_none_or(|usage| usage == "sig"), "OIDC JWK is not a signing key"); + ensure!(key.get("alg").and_then(Value::as_str).is_none_or(|declared| declared == alg), "OIDC JWK algorithm mismatch"); match alg { "RS256" => { ensure!(field(key, "kty")? == "RSA", "OIDC key type mismatch"); let n = key_bytes(key, "n")?; let e = key_bytes(key, "e")?; signature::RsaPublicKeyComponents { n: &n, e: &e } - .verify( - &signature::RSA_PKCS1_2048_8192_SHA256, - signed, - signature_bytes, - ) + .verify(&signature::RSA_PKCS1_2048_8192_SHA256, signed, signature_bytes) .map_err(|_| anyhow::anyhow!("invalid OIDC signature"))?; } "ES256" => { - ensure!( - field(key, "kty")? == "EC" && field(key, "crv")? == "P-256", - "OIDC key type mismatch" - ); + ensure!(field(key, "kty")? == "EC" && field(key, "crv")? == "P-256", "OIDC key type mismatch"); let x = key_bytes(key, "x")?; let y = key_bytes(key, "y")?; - ensure!( - x.len() == 32 && y.len() == 32, - "invalid OIDC curve coordinates" - ); + ensure!(x.len() == 32 && y.len() == 32, "invalid OIDC curve coordinates"); let mut public = Vec::with_capacity(65); public.push(4); public.extend_from_slice(&x); @@ -92,172 +69,79 @@ pub(super) fn verify_id_token( let header: Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(head)?)?; let claims: Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload)?)?; let alg = field(&header, "alg")?; - ensure!( - matches!(alg, "RS256" | "ES256"), - "OIDC JWT algorithm not allowed" - ); + ensure!(matches!(alg, "RS256" | "ES256"), "OIDC JWT algorithm not allowed"); let kid = header.get("kid").and_then(Value::as_str); - let keys = jwks - .get("keys") - .and_then(Value::as_array) - .context("invalid OIDC JWKS")?; + let keys = jwks.get("keys").and_then(Value::as_array).context("invalid OIDC JWKS")?; ensure!(keys.len() <= 32, "too many OIDC signing keys"); - let candidates: Vec<_> = keys - .iter() - .filter(|key| { - kid.map_or(true, |id| { - key.get("kid").and_then(Value::as_str) == Some(id) - }) && key - .get("alg") - .and_then(Value::as_str) - .is_none_or(|configured| configured == alg) - && key - .get("use") - .and_then(Value::as_str) - .is_none_or(|configured| configured == "sig") - }) - .collect(); - ensure!( - candidates.len() == 1, - "OIDC signing key missing or ambiguous" - ); + let candidates: Vec<_> = keys.iter().filter(|key| { + kid.map_or(true, |id| key.get("kid").and_then(Value::as_str) == Some(id)) + && key.get("alg").and_then(Value::as_str).is_none_or(|configured| configured == alg) + && key.get("use").and_then(Value::as_str).is_none_or(|configured| configured == "sig") + }).collect(); + ensure!(candidates.len() == 1, "OIDC signing key missing or ambiguous"); let signed = format!("{head}.{payload}"); - validate_signature( - candidates[0], - alg, - signed.as_bytes(), - &URL_SAFE_NO_PAD.decode(signature)?, - )?; - ensure!( - field(&claims, "iss")? == expected_issuer, - "OIDC issuer mismatch" - ); + validate_signature(candidates[0], alg, signed.as_bytes(), &URL_SAFE_NO_PAD.decode(signature)?)?; + ensure!(field(&claims, "iss")? == expected_issuer, "OIDC issuer mismatch"); let audience = claims.get("aud").context("missing OIDC audience")?; let audiences: Vec<&str> = match audience { Value::String(value) => vec![value], - Value::Array(values) => values - .iter() - .map(|value| value.as_str().context("invalid OIDC audience")) - .collect::>()?, + Value::Array(values) => values.iter().map(|value| value.as_str().context("invalid OIDC audience")).collect::>()?, _ => bail!("invalid OIDC audience"), }; ensure!(audiences.contains(&client_id), "OIDC audience mismatch"); if audiences.len() > 1 { - ensure!( - field(&claims, "azp")? == client_id, - "OIDC authorized party mismatch" - ); + ensure!(field(&claims, "azp")? == client_id, "OIDC authorized party mismatch"); } else if let Some(azp) = claims.get("azp").and_then(Value::as_str) { ensure!(azp == client_id, "OIDC authorized party mismatch"); } - ensure!( - field(&claims, "nonce")? == expected_nonce, - "OIDC nonce mismatch" - ); + ensure!(field(&claims, "nonce")? == expected_nonce, "OIDC nonce mismatch"); let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); - let exp = claims - .get("exp") - .and_then(Value::as_u64) - .context("missing OIDC expiration")?; + let exp = claims.get("exp").and_then(Value::as_u64).context("missing OIDC expiration")?; ensure!(exp > now, "OIDC ID token expired"); - let issued = claims - .get("iat") - .and_then(Value::as_u64) - .context("missing OIDC issuance time")?; - ensure!( - issued <= now.saturating_add(60), - "OIDC token issued in the future" - ); + let issued = claims.get("iat").and_then(Value::as_u64).context("missing OIDC issuance time")?; + ensure!(issued <= now.saturating_add(60), "OIDC token issued in the future"); if let Some(nbf) = claims.get("nbf") { - ensure!( - nbf.as_u64() - .is_some_and(|start| start <= now.saturating_add(60)), - "OIDC token is not yet valid" - ); + ensure!(nbf.as_u64().is_some_and(|start| start <= now.saturating_add(60)), "OIDC token is not yet valid"); } let subject = field(&claims, "sub")?.to_string(); - ensure!( - !subject.is_empty() && subject.len() <= 256, - "invalid OIDC subject" - ); - let username = claims - .get("preferred_username") - .and_then(Value::as_str) + ensure!(!subject.is_empty() && subject.len() <= 256, "invalid OIDC subject"); + let username = claims.get("preferred_username").and_then(Value::as_str) .filter(|name| !name.is_empty() && name.len() <= 256) - .unwrap_or(&subject) - .to_string(); - let optional = |key| { - claims - .get(key) - .and_then(Value::as_str) - .filter(|value| !value.is_empty() && value.len() <= 256) - .map(str::to_string) - }; - let groups = claims - .get("groups") - .and_then(Value::as_array) - .map(|items| { - items - .iter() - .filter_map(Value::as_str) - .filter(|s| !s.is_empty() && s.len() <= 128) - .take(32) - .map(str::to_string) - .collect() - }) + .unwrap_or(&subject).to_string(); + let optional = |key| claims.get(key).and_then(Value::as_str) + .filter(|value| !value.is_empty() && value.len() <= 256).map(str::to_string); + let groups = claims.get("groups").and_then(Value::as_array) + .map(|items| items.iter().filter_map(Value::as_str).filter(|s| !s.is_empty() && s.len() <= 128).take(32).map(str::to_string).collect()) .unwrap_or_default(); let email = if claims.get("email_verified").and_then(Value::as_bool) == Some(false) { None } else { optional("email") }; - Ok(Claims { - subject, - username, - email, - name: optional("name"), - groups, - }) + Ok(Claims { subject, username, email, name: optional("name"), groups }) } + pub(super) fn merge_userinfo(claims: &mut Claims, value: &Value) -> Result<()> { - ensure!( - field(value, "sub")? == claims.subject, - "OIDC UserInfo subject mismatch" - ); + ensure!(field(value, "sub")? == claims.subject, "OIDC UserInfo subject mismatch"); if claims.username == claims.subject { - if let Some(username) = value - .get("preferred_username") - .and_then(Value::as_str) - .filter(|name| !name.is_empty() && name.len() <= 256) - { + if let Some(username) = value.get("preferred_username").and_then(Value::as_str) + .filter(|name| !name.is_empty() && name.len() <= 256) { claims.username = username.to_string(); } } if claims.name.is_none() { - claims.name = value - .get("name") - .and_then(Value::as_str) - .filter(|name| !name.is_empty() && name.len() <= 256) - .map(str::to_string); + claims.name = value.get("name").and_then(Value::as_str) + .filter(|name| !name.is_empty() && name.len() <= 256).map(str::to_string); } - if claims.email.is_none() && value.get("email_verified").and_then(Value::as_bool) != Some(false) - { - claims.email = value - .get("email") - .and_then(Value::as_str) - .filter(|name| !name.is_empty() && name.len() <= 256) - .map(str::to_string); + if claims.email.is_none() && value.get("email_verified").and_then(Value::as_bool) != Some(false) { + claims.email = value.get("email").and_then(Value::as_str) + .filter(|name| !name.is_empty() && name.len() <= 256).map(str::to_string); } if claims.groups.is_empty() { if let Some(groups) = value.get("groups").and_then(Value::as_array) { - claims.groups = groups - .iter() - .filter_map(Value::as_str) - .filter(|name| !name.is_empty() && name.len() <= 128) - .take(32) - .map(str::to_string) - .collect(); + claims.groups = groups.iter().filter_map(Value::as_str) + .filter(|name| !name.is_empty() && name.len() <= 128).take(32).map(str::to_string).collect(); } } Ok(()) diff --git a/src/vhost/validation.rs b/src/vhost/validation.rs index 0da6834..224f5bc 100644 --- a/src/vhost/validation.rs +++ b/src/vhost/validation.rs @@ -254,14 +254,8 @@ impl Vhost { .validate()?; if self.oidc.enabled { let redirect = url::Url::parse(&self.oidc.redirect_url)?; - ensure!( - redirect.host_str().is_some_and(|host| self - .frontend - .hosts - .iter() - .any(|name| name == host)), - "OIDC callback host must be an exact Vhost hostname" - ); + ensure!(redirect.host_str().is_some_and(|host| self.frontend.hosts.iter().any(|name| name == host)), + "OIDC callback host must be an exact Vhost hostname"); } self.pages.validate()?; self.transform diff --git a/tests/vhost_auth_selection.js b/tests/vhost_auth_selection.js new file mode 100644 index 0000000..454a01c --- /dev/null +++ b/tests/vhost_auth_selection.js @@ -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");