diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c60cf5..59736e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,51 @@ All notable changes to the Firefly Framework for Rust. +## v26.6.36 — 2026-06-20 + +**Spring Security parity — Tier 5b: SAML2 single sign-on (SP side).** The +Service-Provider half of the SAML 2.0 Web-Browser-SSO profile — Spring's +`saml2Login()` — delegating XML-signature verification to `samael` and adding a +Spring-faithful, hardened wrapper. Opt-in `saml2` feature; the default build is +unaffected. Adversarially reviewed before release. + +### Added + +- **`saml2` feature** (opt-in, pulls in `samael` + a system `libxml2` / `xmlsec1` + / OpenSSL): + - **`RelyingPartyRegistration`** + builder + **`InMemoryRelyingPartyRegistrationRepository`** + (Spring's `RelyingPartyRegistration` / repository) — configured from IdP + metadata XML or explicit asserting-party details. + - **SP-initiated `AuthnRequest`** — `authn_request_redirect` (HTTP-Redirect + binding) + **`Saml2AuthenticationRequestRepository`** (TTL'd outgoing + request-id store for `InResponseTo` matching). + - **`authenticate`** — verifies a POST-binding SAML `Response` (signature + + audience / recipient / `InResponseTo` / status / time conditions, via + `samael`) and maps the `NameID` + configured attributes to an + `Authentication` (Spring's `OpenSaml4AuthenticationProvider`). + - **`metadata_xml`** — SP metadata generation (Spring's `Saml2MetadataFilter`). + - **`AssertionReplayCache`** + **`InMemoryAssertionReplayCache`** — one-time-use + assertion replay protection. + +### Security + +- **Fail-closed on a missing IdP signing certificate**: building a registration + is rejected when the asserting party has no signing cert, because `samael` + would otherwise skip signature verification entirely (an authentication bypass). +- **Signature-algorithm allow-list** pinned to SHA-256+ RSA/ECDSA by default + (`samael` otherwise accepts all algorithms — an algorithm-substitution risk). +- **One-time-use replay protection** the SAML profile requires but `samael` does + not track; **size-bounded** response decoding; and all native XML-Security + calls are **serialized** (the stack is not concurrency-safe). + +### Notes + +- Single-logout, signed `AuthnRequest`s, and encrypted assertions are follow-ups. +- Verification correctness rests on `samael` (whose own crypto suite covers + accept/reject of XML signatures); this module's registration, mapping, replay, + and rejection logic are unit-tested. The `saml2` feature's tests require the + XML-Security system libraries and so run only when the feature is enabled. + ## v26.6.35 — 2026-06-20 **Spring Security parity — Tier 5c: ACL / domain-object security.** The Rust diff --git a/Cargo.lock b/Cargo.lock index db243de..1545501 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aead" version = "0.5.2" @@ -640,6 +646,26 @@ dependencies = [ "zeroize", ] +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.0", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.117", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -795,7 +821,16 @@ dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", ] [[package]] @@ -849,6 +884,17 @@ dependencies = [ "inout", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" version = "4.6.1" @@ -1060,6 +1106,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "critical-section" version = "1.2.0" @@ -1325,6 +1380,37 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1548,7 +1634,7 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "firefly" -version = "26.6.35" +version = "26.6.36" dependencies = [ "axum", "firefly-actuator", @@ -1592,7 +1678,7 @@ dependencies = [ [[package]] name = "firefly-actuator" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "axum", @@ -1609,7 +1695,7 @@ dependencies = [ [[package]] name = "firefly-admin" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "axum", @@ -1638,7 +1724,7 @@ dependencies = [ [[package]] name = "firefly-aop" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "inventory", @@ -1648,7 +1734,7 @@ dependencies = [ [[package]] name = "firefly-backoffice" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "axum", @@ -1667,7 +1753,7 @@ dependencies = [ [[package]] name = "firefly-cache" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "firefly-observability", @@ -1679,7 +1765,7 @@ dependencies = [ [[package]] name = "firefly-cache-postgres" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "chrono", @@ -1691,7 +1777,7 @@ dependencies = [ [[package]] name = "firefly-cache-redis" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "firefly-cache", @@ -1703,7 +1789,7 @@ dependencies = [ [[package]] name = "firefly-callbacks" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "axum", @@ -1729,7 +1815,7 @@ dependencies = [ [[package]] name = "firefly-cli" -version = "26.6.35" +version = "26.6.36" dependencies = [ "axum", "chrono", @@ -1750,7 +1836,7 @@ dependencies = [ [[package]] name = "firefly-client" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-stream", "axum", @@ -1772,7 +1858,7 @@ dependencies = [ [[package]] name = "firefly-config" -version = "26.6.35" +version = "26.6.36" dependencies = [ "axum", "regex", @@ -1787,7 +1873,7 @@ dependencies = [ [[package]] name = "firefly-config-server" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "axum", @@ -1803,7 +1889,7 @@ dependencies = [ [[package]] name = "firefly-container" -version = "26.6.35" +version = "26.6.36" dependencies = [ "futures", "inventory", @@ -1814,7 +1900,7 @@ dependencies = [ [[package]] name = "firefly-cqrs" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "chrono", @@ -1837,7 +1923,7 @@ dependencies = [ [[package]] name = "firefly-data" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-stream", "async-trait", @@ -1854,7 +1940,7 @@ dependencies = [ [[package]] name = "firefly-data-mongodb" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-stream", "async-trait", @@ -1872,7 +1958,7 @@ dependencies = [ [[package]] name = "firefly-data-sqlx" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-stream", "async-trait", @@ -1894,7 +1980,7 @@ dependencies = [ [[package]] name = "firefly-ecm" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "chrono", @@ -1910,7 +1996,7 @@ dependencies = [ [[package]] name = "firefly-ecm-esignature-adobe-sign" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "axum", @@ -1927,7 +2013,7 @@ dependencies = [ [[package]] name = "firefly-ecm-esignature-docusign" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "axum", @@ -1944,7 +2030,7 @@ dependencies = [ [[package]] name = "firefly-ecm-esignature-logalty" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "axum", @@ -1961,7 +2047,7 @@ dependencies = [ [[package]] name = "firefly-ecm-storage-aws" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "axum", @@ -1981,7 +2067,7 @@ dependencies = [ [[package]] name = "firefly-ecm-storage-azure" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "axum", @@ -2001,7 +2087,7 @@ dependencies = [ [[package]] name = "firefly-eda" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "base64 0.22.1", @@ -2023,7 +2109,7 @@ dependencies = [ [[package]] name = "firefly-eda-kafka" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "firefly-eda", @@ -2038,7 +2124,7 @@ dependencies = [ [[package]] name = "firefly-eda-postgres" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "chrono", @@ -2056,7 +2142,7 @@ dependencies = [ [[package]] name = "firefly-eda-rabbitmq" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "firefly-eda", @@ -2071,7 +2157,7 @@ dependencies = [ [[package]] name = "firefly-eda-redis" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "firefly-eda", @@ -2087,7 +2173,7 @@ dependencies = [ [[package]] name = "firefly-eventsourcing" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "base64 0.22.1", @@ -2105,7 +2191,7 @@ dependencies = [ [[package]] name = "firefly-i18n" -version = "26.6.35" +version = "26.6.36" dependencies = [ "axum", "http", @@ -2120,7 +2206,7 @@ dependencies = [ [[package]] name = "firefly-idp" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "axum", @@ -2136,7 +2222,7 @@ dependencies = [ [[package]] name = "firefly-idp-aws-cognito" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "axum", @@ -2157,7 +2243,7 @@ dependencies = [ [[package]] name = "firefly-idp-azure-ad" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "axum", @@ -2174,7 +2260,7 @@ dependencies = [ [[package]] name = "firefly-idp-internal-db" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "axum", @@ -2196,7 +2282,7 @@ dependencies = [ [[package]] name = "firefly-idp-keycloak" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "axum", @@ -2213,7 +2299,7 @@ dependencies = [ [[package]] name = "firefly-integration-tests" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "axum", @@ -2248,7 +2334,7 @@ dependencies = [ [[package]] name = "firefly-kernel" -version = "26.6.35" +version = "26.6.36" dependencies = [ "chrono", "serde", @@ -2260,7 +2346,7 @@ dependencies = [ [[package]] name = "firefly-lifecycle" -version = "26.6.35" +version = "26.6.36" dependencies = [ "thiserror 1.0.69", "tokio", @@ -2269,7 +2355,7 @@ dependencies = [ [[package]] name = "firefly-macros" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "axum", @@ -2293,7 +2379,7 @@ dependencies = [ [[package]] name = "firefly-migrations" -version = "26.6.35" +version = "26.6.36" dependencies = [ "chrono", "hex", @@ -2306,7 +2392,7 @@ dependencies = [ [[package]] name = "firefly-notifications" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "chrono", @@ -2321,7 +2407,7 @@ dependencies = [ [[package]] name = "firefly-notifications-firebase" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "axum", @@ -2337,7 +2423,7 @@ dependencies = [ [[package]] name = "firefly-notifications-resend" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "axum", @@ -2354,7 +2440,7 @@ dependencies = [ [[package]] name = "firefly-notifications-sendgrid" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "axum", @@ -2371,7 +2457,7 @@ dependencies = [ [[package]] name = "firefly-notifications-smtp" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "base64 0.22.1", @@ -2388,7 +2474,7 @@ dependencies = [ [[package]] name = "firefly-notifications-twilio" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "axum", @@ -2404,7 +2490,7 @@ dependencies = [ [[package]] name = "firefly-observability" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "chrono", @@ -2428,7 +2514,7 @@ dependencies = [ [[package]] name = "firefly-openapi" -version = "26.6.35" +version = "26.6.36" dependencies = [ "axum", "chrono", @@ -2444,7 +2530,7 @@ dependencies = [ [[package]] name = "firefly-orchestration" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "axum", @@ -2468,7 +2554,7 @@ dependencies = [ [[package]] name = "firefly-plugins" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "chrono", @@ -2478,7 +2564,7 @@ dependencies = [ [[package]] name = "firefly-reactive" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-stream", "firefly-kernel", @@ -2490,7 +2576,7 @@ dependencies = [ [[package]] name = "firefly-resilience" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "firefly-config", @@ -2502,7 +2588,7 @@ dependencies = [ [[package]] name = "firefly-rule-engine" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "axum", @@ -2522,7 +2608,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "axum", @@ -2539,7 +2625,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-core" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "chrono", @@ -2554,7 +2640,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-interfaces" -version = "26.6.35" +version = "26.6.36" dependencies = [ "chrono", "firefly", @@ -2565,7 +2651,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-models" -version = "26.6.35" +version = "26.6.36" dependencies = [ "chrono", "firefly", @@ -2578,7 +2664,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-sdk" -version = "26.6.35" +version = "26.6.36" dependencies = [ "firefly-client", "firefly-sample-lumen-ledger-interfaces", @@ -2590,7 +2676,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-web" -version = "26.6.35" +version = "26.6.36" dependencies = [ "axum", "firefly", @@ -2608,7 +2694,7 @@ dependencies = [ [[package]] name = "firefly-sample-macro-quickstart" -version = "26.6.35" +version = "26.6.36" dependencies = [ "axum", "firefly", @@ -2621,7 +2707,7 @@ dependencies = [ [[package]] name = "firefly-sample-orders" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "axum", @@ -2644,7 +2730,7 @@ dependencies = [ [[package]] name = "firefly-sample-reactive-banking" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-stream", "async-trait", @@ -2684,7 +2770,7 @@ dependencies = [ [[package]] name = "firefly-scheduling" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "chrono", @@ -2705,7 +2791,7 @@ dependencies = [ [[package]] name = "firefly-security" -version = "26.6.35" +version = "26.6.36" dependencies = [ "argon2", "async-trait", @@ -2722,6 +2808,7 @@ dependencies = [ "rand 0.8.6", "redis", "reqwest", + "samael", "serde", "serde_json", "sha2 0.10.9", @@ -2737,7 +2824,7 @@ dependencies = [ [[package]] name = "firefly-session" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "axum", @@ -2762,7 +2849,7 @@ dependencies = [ [[package]] name = "firefly-session-mongodb" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "firefly-session", @@ -2775,7 +2862,7 @@ dependencies = [ [[package]] name = "firefly-session-postgres" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "firefly-session", @@ -2787,7 +2874,7 @@ dependencies = [ [[package]] name = "firefly-session-redis" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "firefly-session", @@ -2799,7 +2886,7 @@ dependencies = [ [[package]] name = "firefly-shell" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "futures", @@ -2809,7 +2896,7 @@ dependencies = [ [[package]] name = "firefly-spike-linkspike-core" -version = "26.6.35" +version = "26.6.36" dependencies = [ "axum", "firefly", @@ -2817,7 +2904,7 @@ dependencies = [ [[package]] name = "firefly-spike-linkspike-web" -version = "26.6.35" +version = "26.6.36" dependencies = [ "axum", "firefly", @@ -2829,7 +2916,7 @@ dependencies = [ [[package]] name = "firefly-sse" -version = "26.6.35" +version = "26.6.36" dependencies = [ "axum", "bytes", @@ -2845,7 +2932,7 @@ dependencies = [ [[package]] name = "firefly-starter-application" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "firefly-cqrs", @@ -2859,7 +2946,7 @@ dependencies = [ [[package]] name = "firefly-starter-core" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "axum", @@ -2885,7 +2972,7 @@ dependencies = [ [[package]] name = "firefly-starter-data" -version = "26.6.35" +version = "26.6.36" dependencies = [ "axum", "firefly-cqrs", @@ -2898,7 +2985,7 @@ dependencies = [ [[package]] name = "firefly-starter-domain" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "firefly-eventsourcing", @@ -2910,7 +2997,7 @@ dependencies = [ [[package]] name = "firefly-starter-experience" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "axum", @@ -2931,7 +3018,7 @@ dependencies = [ [[package]] name = "firefly-starter-web" -version = "26.6.35" +version = "26.6.36" dependencies = [ "axum", "firefly-kernel", @@ -2946,7 +3033,7 @@ dependencies = [ [[package]] name = "firefly-testkit" -version = "26.6.35" +version = "26.6.36" dependencies = [ "axum", "base64 0.22.1", @@ -2965,7 +3052,7 @@ dependencies = [ [[package]] name = "firefly-transactional" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "inventory", @@ -2976,7 +3063,7 @@ dependencies = [ [[package]] name = "firefly-utils" -version = "26.6.35" +version = "26.6.36" dependencies = [ "aes-gcm", "base64 0.22.1", @@ -2992,7 +3079,7 @@ dependencies = [ [[package]] name = "firefly-validators" -version = "26.6.35" +version = "26.6.36" dependencies = [ "chrono", "firefly-kernel", @@ -3003,7 +3090,7 @@ dependencies = [ [[package]] name = "firefly-web" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "axum", @@ -3025,7 +3112,7 @@ dependencies = [ "http-body", "http-body-util", "inventory", - "quick-xml", + "quick-xml 0.36.2", "rand 0.8.6", "regex", "rustls 0.23.40", @@ -3042,7 +3129,7 @@ dependencies = [ [[package]] name = "firefly-webhooks" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "axum", @@ -3070,7 +3157,7 @@ dependencies = [ [[package]] name = "firefly-websocket" -version = "26.6.35" +version = "26.6.36" dependencies = [ "async-trait", "axum", @@ -3094,6 +3181,16 @@ version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "flume" version = "0.11.1" @@ -4197,6 +4294,16 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libm" version = "0.2.16" @@ -4226,6 +4333,17 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libxml" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fe73cdec2bcb36d25a9fe3f607ffcd44bb8907ca0100c4098d1aa342d1e7bec" +dependencies = [ + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "libz-sys" version = "1.1.29" @@ -4388,6 +4506,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.1" @@ -5192,6 +5320,16 @@ dependencies = [ "serde", ] +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quote" version = "1.0.45" @@ -5557,6 +5695,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustc_version" version = "0.4.1" @@ -5760,6 +5904,33 @@ dependencies = [ "cipher", ] +[[package]] +name = "samael" +version = "0.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2111ff2928f84917f762410b9dfa8ae80cea92467334a2ffe83a959c29542cae" +dependencies = [ + "base64 0.22.1", + "bindgen", + "chrono", + "data-encoding", + "derive_builder", + "flate2", + "lazy_static", + "libc", + "libxml", + "openssl", + "openssl-probe 0.1.6", + "openssl-sys", + "pkg-config", + "quick-xml 0.37.5", + "rand 0.9.4", + "serde", + "thiserror 2.0.18", + "url", + "uuid", +] + [[package]] name = "same-file" version = "1.0.6" @@ -6026,6 +6197,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "shlex" version = "2.0.1" @@ -6052,6 +6229,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "simd_cesu8" version = "1.1.1" diff --git a/Cargo.toml b/Cargo.toml index 76bcb8b..6139ee5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,7 +90,7 @@ members = [ ] [workspace.package] -version = "26.6.35" +version = "26.6.36" edition = "2021" license = "Apache-2.0" repository = "https://github.com/fireflyframework/fireflyframework-rust" @@ -99,80 +99,80 @@ rust-version = "1.88" [workspace.dependencies] # ---- internal crates ---- -firefly-reactive = { path = "crates/reactive", version = "26.6.35" } -firefly-kernel = { path = "crates/kernel", version = "26.6.35" } -firefly-utils = { path = "crates/utils", version = "26.6.35" } -firefly-validators = { path = "crates/validators", version = "26.6.35" } -firefly-web = { path = "crates/web", version = "26.6.35" } -firefly-config = { path = "crates/config", version = "26.6.35" } -firefly-i18n = { path = "crates/i18n", version = "26.6.35" } -firefly-cache = { path = "crates/cache", version = "26.6.35" } -firefly-observability = { path = "crates/observability", version = "26.6.35" } -firefly-data = { path = "crates/data", version = "26.6.35" } -firefly-cqrs = { path = "crates/cqrs", version = "26.6.35" } -firefly-eda = { path = "crates/eda", version = "26.6.35" } -firefly-eventsourcing = { path = "crates/eventsourcing", version = "26.6.35" } -firefly-orchestration = { path = "crates/orchestration", version = "26.6.35" } -firefly-rule-engine = { path = "crates/rule-engine", version = "26.6.35" } -firefly-plugins = { path = "crates/plugins", version = "26.6.35" } -firefly-lifecycle = { path = "crates/lifecycle", version = "26.6.35" } -firefly-actuator = { path = "crates/actuator", version = "26.6.35" } -firefly-scheduling = { path = "crates/scheduling", version = "26.6.35" } -firefly-resilience = { path = "crates/resilience", version = "26.6.35" } -firefly-security = { path = "crates/security", version = "26.6.35" } -firefly-migrations = { path = "crates/migrations", version = "26.6.35" } -firefly-openapi = { path = "crates/openapi", version = "26.6.35" } -firefly-sse = { path = "crates/sse", version = "26.6.35" } -firefly-transactional = { path = "crates/transactional", version = "26.6.35" } -firefly-testkit = { path = "crates/testkit", version = "26.6.35" } -firefly-client = { path = "crates/client", version = "26.6.35" } -firefly-config-server = { path = "crates/config-server", version = "26.6.35" } -firefly-idp = { path = "crates/idp", version = "26.6.35" } -firefly-idp-internal-db = { path = "crates/idp-internal-db", version = "26.6.35" } -firefly-idp-keycloak = { path = "crates/idp-keycloak", version = "26.6.35" } -firefly-idp-azure-ad = { path = "crates/idp-azure-ad", version = "26.6.35" } -firefly-idp-aws-cognito = { path = "crates/idp-aws-cognito", version = "26.6.35" } -firefly-ecm = { path = "crates/ecm", version = "26.6.35" } -firefly-ecm-storage-aws = { path = "crates/ecm-storage-aws", version = "26.6.35" } -firefly-ecm-storage-azure = { path = "crates/ecm-storage-azure", version = "26.6.35" } -firefly-ecm-esignature-docusign = { path = "crates/ecm-esignature-docusign", version = "26.6.35" } -firefly-ecm-esignature-adobe-sign = { path = "crates/ecm-esignature-adobe-sign", version = "26.6.35" } -firefly-ecm-esignature-logalty = { path = "crates/ecm-esignature-logalty", version = "26.6.35" } -firefly-notifications = { path = "crates/notifications", version = "26.6.35" } -firefly-notifications-sendgrid = { path = "crates/notifications-sendgrid", version = "26.6.35" } -firefly-notifications-resend = { path = "crates/notifications-resend", version = "26.6.35" } -firefly-notifications-twilio = { path = "crates/notifications-twilio", version = "26.6.35" } -firefly-notifications-firebase = { path = "crates/notifications-firebase", version = "26.6.35" } -firefly-callbacks = { path = "crates/callbacks", version = "26.6.35" } -firefly-webhooks = { path = "crates/webhooks", version = "26.6.35" } -firefly-starter-core = { path = "crates/starter-core", version = "26.6.35" } -firefly-starter-application = { path = "crates/starter-application", version = "26.6.35" } -firefly-starter-domain = { path = "crates/starter-domain", version = "26.6.35" } -firefly-starter-data = { path = "crates/starter-data", version = "26.6.35" } -firefly-backoffice = { path = "crates/backoffice", version = "26.6.35" } -firefly-admin = { path = "crates/admin", version = "26.6.35" } -firefly-aop = { path = "crates/aop", version = "26.6.35" } -firefly-cli = { path = "crates/cli", version = "26.6.35" } -firefly-container = { path = "crates/container", version = "26.6.35" } -firefly-session = { path = "crates/session", version = "26.6.35" } -firefly-shell = { path = "crates/shell", version = "26.6.35" } -firefly-websocket = { path = "crates/websocket", version = "26.6.35" } -firefly-notifications-smtp = { path = "crates/notifications-smtp", version = "26.6.35" } -firefly-cache-redis = { path = "crates/cache-redis", version = "26.6.35" } -firefly-eda-kafka = { path = "crates/eda-kafka", version = "26.6.35" } -firefly-eda-rabbitmq = { path = "crates/eda-rabbitmq", version = "26.6.35" } -firefly-eda-postgres = { path = "crates/eda-postgres", version = "26.6.35" } -firefly-eda-redis = { path = "crates/eda-redis", version = "26.6.35" } -firefly-cache-postgres = { path = "crates/cache-postgres", version = "26.6.35" } -firefly-starter-web = { path = "crates/starter-web", version = "26.6.35" } -firefly = { path = "crates/firefly", version = "26.6.35" } -firefly-macros = { path = "crates/macros", version = "26.6.35" } -firefly-data-sqlx = { path = "crates/data-sqlx", version = "26.6.35" } -firefly-data-mongodb = { path = "crates/data-mongodb", version = "26.6.35" } -firefly-session-redis = { path = "crates/session-redis", version = "26.6.35" } -firefly-session-postgres = { path = "crates/session-postgres", version = "26.6.35" } -firefly-session-mongodb = { path = "crates/session-mongodb", version = "26.6.35" } -firefly-starter-experience = { path = "crates/starter-experience", version = "26.6.35" } +firefly-reactive = { path = "crates/reactive", version = "26.6.36" } +firefly-kernel = { path = "crates/kernel", version = "26.6.36" } +firefly-utils = { path = "crates/utils", version = "26.6.36" } +firefly-validators = { path = "crates/validators", version = "26.6.36" } +firefly-web = { path = "crates/web", version = "26.6.36" } +firefly-config = { path = "crates/config", version = "26.6.36" } +firefly-i18n = { path = "crates/i18n", version = "26.6.36" } +firefly-cache = { path = "crates/cache", version = "26.6.36" } +firefly-observability = { path = "crates/observability", version = "26.6.36" } +firefly-data = { path = "crates/data", version = "26.6.36" } +firefly-cqrs = { path = "crates/cqrs", version = "26.6.36" } +firefly-eda = { path = "crates/eda", version = "26.6.36" } +firefly-eventsourcing = { path = "crates/eventsourcing", version = "26.6.36" } +firefly-orchestration = { path = "crates/orchestration", version = "26.6.36" } +firefly-rule-engine = { path = "crates/rule-engine", version = "26.6.36" } +firefly-plugins = { path = "crates/plugins", version = "26.6.36" } +firefly-lifecycle = { path = "crates/lifecycle", version = "26.6.36" } +firefly-actuator = { path = "crates/actuator", version = "26.6.36" } +firefly-scheduling = { path = "crates/scheduling", version = "26.6.36" } +firefly-resilience = { path = "crates/resilience", version = "26.6.36" } +firefly-security = { path = "crates/security", version = "26.6.36" } +firefly-migrations = { path = "crates/migrations", version = "26.6.36" } +firefly-openapi = { path = "crates/openapi", version = "26.6.36" } +firefly-sse = { path = "crates/sse", version = "26.6.36" } +firefly-transactional = { path = "crates/transactional", version = "26.6.36" } +firefly-testkit = { path = "crates/testkit", version = "26.6.36" } +firefly-client = { path = "crates/client", version = "26.6.36" } +firefly-config-server = { path = "crates/config-server", version = "26.6.36" } +firefly-idp = { path = "crates/idp", version = "26.6.36" } +firefly-idp-internal-db = { path = "crates/idp-internal-db", version = "26.6.36" } +firefly-idp-keycloak = { path = "crates/idp-keycloak", version = "26.6.36" } +firefly-idp-azure-ad = { path = "crates/idp-azure-ad", version = "26.6.36" } +firefly-idp-aws-cognito = { path = "crates/idp-aws-cognito", version = "26.6.36" } +firefly-ecm = { path = "crates/ecm", version = "26.6.36" } +firefly-ecm-storage-aws = { path = "crates/ecm-storage-aws", version = "26.6.36" } +firefly-ecm-storage-azure = { path = "crates/ecm-storage-azure", version = "26.6.36" } +firefly-ecm-esignature-docusign = { path = "crates/ecm-esignature-docusign", version = "26.6.36" } +firefly-ecm-esignature-adobe-sign = { path = "crates/ecm-esignature-adobe-sign", version = "26.6.36" } +firefly-ecm-esignature-logalty = { path = "crates/ecm-esignature-logalty", version = "26.6.36" } +firefly-notifications = { path = "crates/notifications", version = "26.6.36" } +firefly-notifications-sendgrid = { path = "crates/notifications-sendgrid", version = "26.6.36" } +firefly-notifications-resend = { path = "crates/notifications-resend", version = "26.6.36" } +firefly-notifications-twilio = { path = "crates/notifications-twilio", version = "26.6.36" } +firefly-notifications-firebase = { path = "crates/notifications-firebase", version = "26.6.36" } +firefly-callbacks = { path = "crates/callbacks", version = "26.6.36" } +firefly-webhooks = { path = "crates/webhooks", version = "26.6.36" } +firefly-starter-core = { path = "crates/starter-core", version = "26.6.36" } +firefly-starter-application = { path = "crates/starter-application", version = "26.6.36" } +firefly-starter-domain = { path = "crates/starter-domain", version = "26.6.36" } +firefly-starter-data = { path = "crates/starter-data", version = "26.6.36" } +firefly-backoffice = { path = "crates/backoffice", version = "26.6.36" } +firefly-admin = { path = "crates/admin", version = "26.6.36" } +firefly-aop = { path = "crates/aop", version = "26.6.36" } +firefly-cli = { path = "crates/cli", version = "26.6.36" } +firefly-container = { path = "crates/container", version = "26.6.36" } +firefly-session = { path = "crates/session", version = "26.6.36" } +firefly-shell = { path = "crates/shell", version = "26.6.36" } +firefly-websocket = { path = "crates/websocket", version = "26.6.36" } +firefly-notifications-smtp = { path = "crates/notifications-smtp", version = "26.6.36" } +firefly-cache-redis = { path = "crates/cache-redis", version = "26.6.36" } +firefly-eda-kafka = { path = "crates/eda-kafka", version = "26.6.36" } +firefly-eda-rabbitmq = { path = "crates/eda-rabbitmq", version = "26.6.36" } +firefly-eda-postgres = { path = "crates/eda-postgres", version = "26.6.36" } +firefly-eda-redis = { path = "crates/eda-redis", version = "26.6.36" } +firefly-cache-postgres = { path = "crates/cache-postgres", version = "26.6.36" } +firefly-starter-web = { path = "crates/starter-web", version = "26.6.36" } +firefly = { path = "crates/firefly", version = "26.6.36" } +firefly-macros = { path = "crates/macros", version = "26.6.36" } +firefly-data-sqlx = { path = "crates/data-sqlx", version = "26.6.36" } +firefly-data-mongodb = { path = "crates/data-mongodb", version = "26.6.36" } +firefly-session-redis = { path = "crates/session-redis", version = "26.6.36" } +firefly-session-postgres = { path = "crates/session-postgres", version = "26.6.36" } +firefly-session-mongodb = { path = "crates/session-mongodb", version = "26.6.36" } +firefly-starter-experience = { path = "crates/starter-experience", version = "26.6.36" } # ---- async runtime + web ---- tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "signal", "io-util", "net", "fs"] } diff --git a/MODULES.md b/MODULES.md index a77ac4b..7bd121f 100644 --- a/MODULES.md +++ b/MODULES.md @@ -48,7 +48,7 @@ declarative macros instead of hand-rolled builder wiring. | [`firefly-actuator`](crates/actuator/README.md) | `/actuator/{health,info,metrics,env,tasks,version}` + liveness/readiness probes, runtime loggers, `httpexchanges`, `threaddump`, labeled Micrometer metrics, `refresh`, `management.endpoints.web` exposure | | [`firefly-scheduling`](crates/scheduling/README.md) | Cron + FixedRate + FixedDelay `Scheduler` | | [`firefly-resilience`](crates/resilience/README.md) | `CircuitBreaker`, `RateLimiter`, `Bulkhead`, `Timeout`, composable `Chain` | -| [`firefly-security`](crates/security/README.md) | `Authentication` extension, `BearerLayer`, RBAC `FilterChain` (`ROLE_`-aware, path-segment-safe), the authentication spine (`AuthenticationManager`/`ProviderManager`, `UserDetails`+`DaoAuthenticationProvider`, `SecurityContextRepository`, `DelegatingPasswordEncoder`), web mechanisms (`httpBasic`, `formLogin`, `TokenBasedRememberMeServices`, `RequestCache`, `SessionCreationPolicy`, `SecurityFilterChains`), method-security depth (`PermissionEvaluator` + `has_permission`, consumed by the expression `#[pre_authorize]`/`#[post_authorize]`/`#[pre_filter]`/`#[post_filter]` macros), JWKS `JwksVerifier` (RSA/EC/EdDSA, `nbf` + clock-skew), `oauth2` (PKCE/OIDC login + RP-initiated logout, outbound `AuthorizedClientManager`, RFC 7662 opaque-token introspection, authorization server + RFC 8414 `AuthorizationServerRouter`), **one-time-token + WebAuthn/passkey** passwordless login, feature-gated **LDAP/Active-Directory** auth (`LdapAuthenticationProvider`/`ActiveDirectoryLdapAuthenticationProvider`), domain-object **ACL** (`Acl`/`AclService`/`AclPermissionEvaluator`, `spring-security-acl` parity), `RoleHierarchy`, `CsrfLayer`, `PasswordEncoder` (bcrypt + Argon2id) — Spring Security 6-faithful (see the book's *Spring Security Parity* appendix) | +| [`firefly-security`](crates/security/README.md) | `Authentication` extension, `BearerLayer`, RBAC `FilterChain` (`ROLE_`-aware, path-segment-safe), the authentication spine (`AuthenticationManager`/`ProviderManager`, `UserDetails`+`DaoAuthenticationProvider`, `SecurityContextRepository`, `DelegatingPasswordEncoder`), web mechanisms (`httpBasic`, `formLogin`, `TokenBasedRememberMeServices`, `RequestCache`, `SessionCreationPolicy`, `SecurityFilterChains`), method-security depth (`PermissionEvaluator` + `has_permission`, consumed by the expression `#[pre_authorize]`/`#[post_authorize]`/`#[pre_filter]`/`#[post_filter]` macros), JWKS `JwksVerifier` (RSA/EC/EdDSA, `nbf` + clock-skew), `oauth2` (PKCE/OIDC login + RP-initiated logout, outbound `AuthorizedClientManager`, RFC 7662 opaque-token introspection, authorization server + RFC 8414 `AuthorizationServerRouter`), **one-time-token + WebAuthn/passkey** passwordless login, feature-gated **LDAP/Active-Directory** auth (`LdapAuthenticationProvider`/`ActiveDirectoryLdapAuthenticationProvider`), domain-object **ACL** (`Acl`/`AclService`/`AclPermissionEvaluator`, `spring-security-acl` parity), feature-gated **SAML2 SSO** (`RelyingPartyRegistration`, SP-initiated `AuthnRequest`, signed-response verification + replay over `samael`, `saml2Login()`), `RoleHierarchy`, `CsrfLayer`, `PasswordEncoder` (bcrypt + Argon2id) — Spring Security 6-faithful (see the book's *Spring Security Parity* appendix) | | [`firefly-migrations`](crates/migrations/README.md) | Versioned SQL migrations (`V001__init.sql`) over a `Database` port | | [`firefly-openapi`](crates/openapi/README.md) | OpenAPI 3.1 generator + Swagger-UI shim | | [`firefly-sse`](crates/sse/README.md) | Server-Sent Events writer w/ heartbeat + Last-Event-Id | diff --git a/crates/security/Cargo.toml b/crates/security/Cargo.toml index c754c01..1468820 100644 --- a/crates/security/Cargo.toml +++ b/crates/security/Cargo.toml @@ -19,6 +19,12 @@ webauthn = ["dep:webauthn-rs", "dep:uuid"] # not compile the `ldap` module at all. ldap = ["dep:ldap3"] +# SAML 2.0 Service Provider (Spring Security `saml2Login()` parity). Opt-in +# because it pulls in `samael` and its XML-Security C stack (libxml2 + xmlsec1 + +# OpenSSL) for XML-signature verification. The default build does not compile +# the `saml2` module at all and keeps the pure-Rust / rustls posture. +saml2 = ["dep:samael"] + [dependencies] axum = { workspace = true } tower = { workspace = true } @@ -59,6 +65,14 @@ ldap3 = { version = "0.11", optional = true, default-features = false, features "tls-rustls", ] } +# --- `saml2` feature ------------------------------------------------------- +# SAML 2.0 SP toolkit: entity descriptors / metadata, AuthnRequest generation, +# and signed-Response verification via xmlsec (the `xmlsec` feature, on by +# default in samael, is what performs XML-signature validation). Optional so the +# default build is untouched; enabled by the `saml2` feature. Links a system +# dependency on libxml2 + xmlsec1 + OpenSSL. +samael = { version = "0.0.21", optional = true, features = ["xmlsec"] } + [dev-dependencies] tokio = { workspace = true } http-body-util = { workspace = true } diff --git a/crates/security/src/lib.rs b/crates/security/src/lib.rs index 90af9aa..c132166 100644 --- a/crates/security/src/lib.rs +++ b/crates/security/src/lib.rs @@ -160,6 +160,8 @@ mod problem; mod remember_me; mod request_cache; mod role_hierarchy; +#[cfg(feature = "saml2")] +mod saml2; mod security_context; mod security_filter_chains; mod session_auth; @@ -231,6 +233,13 @@ pub use request_cache::{ SESSION_KEY_SAVED_REQUEST, }; pub use role_hierarchy::RoleHierarchy; +#[cfg(feature = "saml2")] +pub use saml2::{ + AllowedSignatureAlgorithm, AssertionReplayCache, AuthnRedirect, InMemoryAssertionReplayCache, + InMemoryRelyingPartyRegistrationRepository, InMemorySaml2AuthenticationRequestRepository, + RelyingPartyRegistration, RelyingPartyRegistrationBuilder, RelyingPartyRegistrationRepository, + Saml2AuthenticationRequestRepository, +}; pub use security_context::{ HttpSessionSecurityContextRepository, NullSecurityContextRepository, SecurityContextRepository, }; diff --git a/crates/security/src/saml2.rs b/crates/security/src/saml2.rs new file mode 100644 index 0000000..907efee --- /dev/null +++ b/crates/security/src/saml2.rs @@ -0,0 +1,1051 @@ +// Copyright 2026 Firefly Software Foundation. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! SAML 2.0 Service Provider authentication — the Rust analog of Spring +//! Security's `saml2Login()` (`RelyingPartyRegistration`, +//! `OpenSaml4AuthenticationProvider`, `Saml2MetadataFilter`). +//! +//! This is the SP side of the SAML 2.0 Web-Browser-SSO profile. The heavy +//! lifting — XML-signature verification, canonicalization, and most SAML profile +//! checks (recipient, `InResponseTo`, status, conditions, subject confirmation) +//! — is delegated to the [`samael`] crate (which links the battle-tested +//! `xmlsec`/`libxml2`/OpenSSL stack). This module is the Spring-faithful, +//! **hardened** wrapper around it: +//! +//! 1. [`RelyingPartyRegistration`] holds one SP↔IdP relationship. Building one +//! **fails closed** when the asserting party (IdP) has no signing +//! certificate — without it `samael` would skip signature verification +//! entirely, which is an authentication bypass. +//! 2. Signature verification is pinned to a safe **allow-list of algorithms** +//! (RSA/ECDSA-SHA256+) — `samael`'s default of "all algorithms" is open to +//! algorithm-substitution attacks. +//! 3. The **audience restriction** is enforced fail-closed: `samael` skips it +//! when the assertion omits `AudienceRestriction`, so [`authenticate`] requires +//! this SP's entity id to be a listed audience. +//! 4. [`AssertionReplayCache`] adds **one-time-use** assertion-ID replay +//! protection, which the SAML profile requires but `samael` does not do. +//! 5. [`Saml2AuthenticationRequestRepository`] tracks outgoing `AuthnRequest` +//! IDs (with a TTL) so a response's `InResponseTo` can be matched to a +//! request this SP actually issued. +//! +//! [`authenticate`]: RelyingPartyRegistration::authenticate +//! +//! Everything here is gated behind the opt-in `saml2` feature, so the default +//! build keeps its pure-Rust / rustls posture. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime}; + +use base64::Engine as _; +pub use samael::crypto::AllowedSignatureAlgorithm; +use samael::metadata::{EntityDescriptor, HTTP_REDIRECT_BINDING}; +use samael::schema::Assertion; +use samael::service_provider::{ServiceProvider, ServiceProviderBuilder}; +use samael::traits::ToXml; +use serde_json::Value; + +use crate::authentication::{Authentication, SecurityError, ROLE_PREFIX}; + +/// The largest decoded SAML response we will parse (a guard against a giant +/// base64 POST body exhausting memory before the XML parser sees it). +const MAX_SAML_RESPONSE_BYTES: usize = 5 * 1024 * 1024; + +/// Replay-cache fallback lifetime for an assertion whose `NotOnOrAfter` is +/// absent — bounds the cache so it cannot grow without limit. +const REPLAY_FALLBACK_TTL: Duration = Duration::from_secs(60 * 60); + +/// Extra time to retain a replay-cache entry past the assertion's validity +/// window, covering the clock skew validation allows. +const REPLAY_SKEW_MARGIN: Duration = Duration::from_secs(300); + +/// `xmlsec`/`libxml2` operate on process-global state and are **not** safe to +/// run concurrently (concurrent use segfaults). Every signature verification — +/// the only entry into the native XML-Security stack — is serialized through +/// this guard. Verification is fast and logins are not a hot path, so the +/// serialization cost is acceptable. +static XMLSEC_GUARD: Mutex<()> = Mutex::new(()); + +/// A safe default signature-algorithm allow-list (SHA-256 and stronger, RSA and +/// ECDSA). `samael` otherwise accepts *all* algorithms, which is open to +/// algorithm-substitution / downgrade attacks. +#[must_use] +fn default_allowed_algorithms() -> Vec { + vec![ + AllowedSignatureAlgorithm::RsaSha256, + AllowedSignatureAlgorithm::RsaSha384, + AllowedSignatureAlgorithm::RsaSha512, + AllowedSignatureAlgorithm::EcdsaSha256, + AllowedSignatureAlgorithm::EcdsaSha384, + AllowedSignatureAlgorithm::EcdsaSha512, + ] +} + +/// Escapes a string for inclusion in an XML attribute / text node. +fn xml_escape(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for ch in value.chars() { + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + other => out.push(other), + } + } + out +} + +/// Synthesises minimal IdP (asserting-party) metadata XML from explicit details, +/// for callers that configure the IdP by entity-id + SSO URL + signing cert +/// rather than by a metadata document (Spring's `assertingPartyDetails`). +fn synthesize_idp_metadata( + entity_id: &str, + sso_redirect_location: &str, + signing_certificate_b64_der: &str, +) -> String { + format!( + concat!( + "", + "", + "", + "", + "{cert}", + "", + "", + "" + ), + eid = xml_escape(entity_id), + cert = xml_escape(signing_certificate_b64_der.trim()), + sso = xml_escape(sso_redirect_location), + ) +} + +/// One relying-party (SP) registration against an asserting party (IdP) — the +/// Rust analog of Spring's `RelyingPartyRegistration`. +/// +/// Build one with [`RelyingPartyRegistration::builder`]. A registration is +/// immutable and cheap to share (wrap it in an [`Arc`] for the repository). +pub struct RelyingPartyRegistration { + registration_id: String, + sp: ServiceProvider, + sso_redirect_location: String, + /// The SP entity id, which is the SAML audience this SP requires. Kept here + /// so [`authenticate`](Self::authenticate) can enforce the AudienceRestriction + /// itself (`samael` skips that check when the restriction is absent). + audience: String, + role_attributes: Vec, + role_prefix: String, +} + +impl RelyingPartyRegistration { + /// Starts building a registration identified by `registration_id` (the + /// opaque key the repository looks it up by, e.g. `"okta"`). + #[must_use] + pub fn builder(registration_id: impl Into) -> RelyingPartyRegistrationBuilder { + RelyingPartyRegistrationBuilder::new(registration_id) + } + + /// The registration's lookup id. + #[must_use] + pub fn registration_id(&self) -> &str { + &self.registration_id + } + + /// Serialises this SP's metadata document (Spring's `Saml2MetadataFilter` + /// output) for registration at the IdP. + pub fn metadata_xml(&self) -> Result { + self.sp + .metadata() + .map_err(|e| SecurityError::verification(format!("saml2: metadata: {e}")))? + .to_string() + .map_err(|e| SecurityError::verification(format!("saml2: metadata: {e}"))) + } + + /// Builds an SP-initiated `AuthnRequest` and returns the HTTP-Redirect-binding + /// URL to send the browser to, plus the request's `ID`. + /// + /// The caller **must persist the returned `request_id`** (see + /// [`Saml2AuthenticationRequestRepository`]) and pass it to + /// [`authenticate`](Self::authenticate) as an expected id, so the response's + /// `InResponseTo` can be matched to a request this SP issued. + pub fn authn_request_redirect( + &self, + relay_state: &str, + ) -> Result { + let request = self + .sp + .make_authentication_request(&self.sso_redirect_location) + .map_err(|e| SecurityError::verification(format!("saml2: authn request: {e}")))?; + let request_id = request.id.clone(); + let url = request + .redirect(relay_state) + .map_err(|e| SecurityError::verification(format!("saml2: authn redirect: {e}")))? + .ok_or_else(|| { + SecurityError::verification("saml2: no IdP SSO destination configured") + })?; + Ok(AuthnRedirect { + url: url.to_string(), + request_id, + }) + } + + /// Validates a base64-encoded SAML `Response` (HTTP-POST binding) and + /// resolves the authenticated principal — the Rust analog of Spring's + /// `OpenSaml4AuthenticationProvider`. + /// + /// `samael` performs XML-signature verification (pinned to the IdP's + /// certificate and this registration's allowed algorithms), recipient, + /// status, `InResponseTo`, and the response/assertion/subject-confirmation + /// time conditions. On top of that, this method enforces the **audience + /// restriction** (fail-closed — `samael` skips it when the assertion omits + /// `AudienceRestriction`) and **one-time-use** of the assertion via + /// `replay_cache` (which `samael` does not track), and rejects an assertion + /// that carries no usable `NameID`. + /// + /// `expected_request_ids` are the `AuthnRequest` IDs this SP issued and is + /// still awaiting; the response's `InResponseTo` must match one of them + /// unless the registration allows IdP-initiated SSO. The caller **must** pass + /// the actual per-login pending id(s) (never a static value) and, after a + /// successful call, retire the matched id via + /// [`Saml2AuthenticationRequestRepository::remove`] so the same `InResponseTo` + /// cannot be reused. + /// + /// # Errors + /// Any signature, profile, audience, replay, or decoding failure — all + /// surfaced as a [`SecurityError::Verification`]. + pub fn authenticate( + &self, + saml_response_b64: &str, + expected_request_ids: &[&str], + replay_cache: &dyn AssertionReplayCache, + ) -> Result { + // Bound the input before allocating the decoded buffer. + if saml_response_b64.len() > MAX_SAML_RESPONSE_BYTES * 2 { + return Err(SecurityError::verification("saml2: response too large")); + } + let decoded = base64::engine::general_purpose::STANDARD + .decode(saml_response_b64.trim()) + .map_err(|_| SecurityError::verification("saml2: response is not valid base64"))?; + if decoded.len() > MAX_SAML_RESPONSE_BYTES { + return Err(SecurityError::verification("saml2: response too large")); + } + let xml = std::str::from_utf8(&decoded) + .map_err(|_| SecurityError::verification("saml2: response is not valid UTF-8"))?; + + // samael verifies the XML signature (against the pinned IdP cert and the + // allowed algorithms) and validates the SAML profile conditions. The + // call is serialized — the native XML-Security stack is not thread-safe. + let assertion = { + let _guard = XMLSEC_GUARD.lock().unwrap_or_else(|e| e.into_inner()); + self.sp.parse_xml_response(xml, Some(expected_request_ids)) + } + .map_err(|e| SecurityError::verification(format!("saml2: {e}")))?; + + // A missing assertion ID would make replay protection collide across + // assertions — refuse it. + if assertion.id.trim().is_empty() { + return Err(SecurityError::verification("saml2: assertion has no ID")); + } + + // Enforce the audience restriction ourselves: `samael` skips the check + // entirely when the assertion has no Conditions/AudienceRestriction, so + // require this SP's entity id to be a listed audience (fail closed). + if !audience_includes(&assertion, &self.audience) { + return Err(SecurityError::verification( + "saml2: assertion audience does not include this service provider", + )); + } + + // One-time-use: reject a replayed assertion (samael does not track this). + // Retain the id slightly past its validity window to cover clock skew. + let expires_at = assertion_expiry(&assertion).map(|t| t + REPLAY_SKEW_MARGIN); + replay_cache.check_and_remember(&assertion.id, expires_at)?; + + let auth = self.map_assertion(assertion); + // A verified login must name a principal; an empty NameID is anonymous + // and would alias across logins, so reject it. + if auth.principal.trim().is_empty() { + return Err(SecurityError::verification( + "saml2: assertion carries no NameID / principal", + )); + } + Ok(auth) + } + + /// Maps a verified [`Assertion`] to an [`Authentication`]: the `NameID` + /// becomes the principal/username, the configured role attributes become + /// authorities (prefixed by [`role_prefix`](RelyingPartyRegistrationBuilder::role_prefix)), + /// and every attribute is exposed in `claims`. + fn map_assertion(&self, assertion: Assertion) -> Authentication { + let principal = assertion + .subject + .as_ref() + .and_then(|s| s.name_id.as_ref()) + .map(|n| n.value.clone()) + .unwrap_or_default(); + + let mut roles = Vec::new(); + // Accumulate per attribute name so that duplicate `` blocks + // (a legal, if uncommon, IdP shape) merge rather than overwrite — keeping + // `claims` consistent with the roles gathered from every block. + let mut attribute_values: HashMap> = HashMap::new(); + for statement in assertion.attribute_statements.iter().flatten() { + for attr in &statement.attributes { + let Some(name) = attr.name.as_deref() else { + continue; + }; + let values: Vec = + attr.values.iter().filter_map(|v| v.value.clone()).collect(); + if self.role_attributes.iter().any(|a| a == name) { + for v in &values { + roles.push(format!("{}{}", self.role_prefix, v)); + } + } + attribute_values + .entry(name.to_string()) + .or_default() + .extend(values); + } + } + let claims = attribute_values + .into_iter() + .map(|(name, values)| { + ( + name, + Value::Array(values.into_iter().map(Value::String).collect()), + ) + }) + .collect(); + + Authentication { + principal: principal.clone(), + username: principal, + roles, + authorities: Vec::new(), + claims, + } + } +} + +/// Whether `assertion` carries an `AudienceRestriction` that lists `audience`. +/// Returns `false` (fail-closed) when there is no `Conditions` / +/// `AudienceRestriction` at all — `samael` would otherwise skip the check. +fn audience_includes(assertion: &Assertion, audience: &str) -> bool { + assertion + .conditions + .as_ref() + .and_then(|c| c.audience_restrictions.as_ref()) + .is_some_and(|restrictions| { + restrictions + .iter() + .any(|r| r.audience.iter().any(|a| a == audience)) + }) +} + +/// The latest moment a verified assertion could still pass validation — the +/// later of its subject-confirmation and conditions `NotOnOrAfter` — as a +/// [`SystemTime`]. `None` when the assertion carries no expiry. +fn assertion_expiry(assertion: &Assertion) -> Option { + let subject_ts = assertion + .subject + .as_ref() + .and_then(|s| s.subject_confirmations.as_ref()) + .into_iter() + .flatten() + .filter_map(|c| c.subject_confirmation_data.as_ref()) + .filter_map(|d| d.not_on_or_after.as_ref().map(|t| t.timestamp())) + .max(); + let condition_ts = assertion + .conditions + .as_ref() + .and_then(|c| c.not_on_or_after.as_ref().map(|t| t.timestamp())); + let ts = [subject_ts, condition_ts].into_iter().flatten().max()?; + u64::try_from(ts) + .ok() + .map(|secs| SystemTime::UNIX_EPOCH + Duration::from_secs(secs)) +} + +/// The outcome of [`RelyingPartyRegistration::authn_request_redirect`]: where to +/// redirect the browser, and the `AuthnRequest` `ID` the caller must remember. +#[derive(Debug, Clone)] +pub struct AuthnRedirect { + /// The IdP SSO URL with the `SAMLRequest` (and `RelayState`) query params. + pub url: String, + /// The generated `AuthnRequest` `ID` — persist it to match `InResponseTo`. + pub request_id: String, +} + +/// Builder for a [`RelyingPartyRegistration`]. +pub struct RelyingPartyRegistrationBuilder { + registration_id: String, + entity_id: Option, + acs_url: Option, + idp_metadata: Option, + sso_redirect_location: Option, + allow_idp_initiated: bool, + allowed_signature_algorithms: Option>, + role_attributes: Vec, + role_prefix: String, +} + +impl RelyingPartyRegistrationBuilder { + fn new(registration_id: impl Into) -> Self { + Self { + registration_id: registration_id.into(), + entity_id: None, + acs_url: None, + idp_metadata: None, + sso_redirect_location: None, + allow_idp_initiated: false, + allowed_signature_algorithms: None, + role_attributes: Vec::new(), + role_prefix: ROLE_PREFIX.to_string(), + } + } + + /// The SP's own entity id (also used as the SAML audience this SP requires). + #[must_use] + pub fn sp_entity_id(mut self, entity_id: impl Into) -> Self { + self.entity_id = Some(entity_id.into()); + self + } + + /// The SP's assertion-consumer-service (ACS) URL — where the IdP POSTs the + /// response, and the `Recipient` the response must match. + #[must_use] + pub fn assertion_consumer_service_location(mut self, acs_url: impl Into) -> Self { + self.acs_url = Some(acs_url.into()); + self + } + + /// Configures the asserting party (IdP) from a SAML metadata document. + /// + /// # Errors + /// If the metadata XML cannot be parsed. + pub fn asserting_party_metadata( + mut self, + idp_metadata_xml: &str, + ) -> Result { + let descriptor: EntityDescriptor = idp_metadata_xml + .parse() + .map_err(|e| SecurityError::verification(format!("saml2: IdP metadata: {e}")))?; + self.idp_metadata = Some(descriptor); + Ok(self) + } + + /// Configures the asserting party (IdP) from explicit details — Spring's + /// `assertingPartyDetails`. `signing_certificate_b64_der` is the IdP's + /// signing certificate as base64-encoded DER (the `` value). + /// + /// # Errors + /// If the synthesised metadata cannot be parsed. + pub fn asserting_party( + mut self, + idp_entity_id: &str, + sso_redirect_location: &str, + signing_certificate_b64_der: &str, + ) -> Result { + let xml = synthesize_idp_metadata( + idp_entity_id, + sso_redirect_location, + signing_certificate_b64_der, + ); + self = self.asserting_party_metadata(&xml)?; + self.sso_redirect_location = Some(sso_redirect_location.to_string()); + Ok(self) + } + + /// Allows IdP-initiated SSO (no matching `AuthnRequest`). Off by default — + /// when off, a response whose `InResponseTo` matches no expected request id + /// is rejected. + /// + /// **Caveat:** enabling this disables `InResponseTo` request-binding (per the + /// IdP-initiated profile — there is no SP request to bind to), so the + /// [`AssertionReplayCache`] becomes the **sole** freshness control. A + /// multi-instance / load-balanced deployment must then supply a *shared* + /// replay cache — the [`InMemoryAssertionReplayCache`] is per-process and + /// cannot stop a replay against a different instance. + #[must_use] + pub fn allow_idp_initiated(mut self, allow: bool) -> Self { + self.allow_idp_initiated = allow; + self + } + + /// Overrides the accepted signature algorithms (default: SHA-256+ RSA/ECDSA). + #[must_use] + pub fn allowed_signature_algorithms( + mut self, + algorithms: Vec, + ) -> Self { + self.allowed_signature_algorithms = Some(algorithms); + self + } + + /// Adds a SAML attribute name whose values are mapped to authorities (roles) + /// on the resulting [`Authentication`]. May be called more than once. + #[must_use] + pub fn role_attribute(mut self, attribute: impl Into) -> Self { + self.role_attributes.push(attribute.into()); + self + } + + /// Overrides the prefix prepended to each role-attribute value (default + /// `ROLE_`). Set it to `""` to use the IdP's values verbatim. + #[must_use] + pub fn role_prefix(mut self, prefix: impl Into) -> Self { + self.role_prefix = prefix.into(); + self + } + + /// Finalises the registration. + /// + /// # Errors + /// - the SP entity id, ACS URL, or IdP metadata is missing; + /// - **the IdP has no signing certificate** (fail-closed: without it, + /// signature verification would be silently skipped); + /// - no HTTP-Redirect SSO location can be resolved. + pub fn build(self) -> Result { + let entity_id = self + .entity_id + .ok_or_else(|| SecurityError::verification("saml2: SP entity id is required"))?; + // The SP entity id is the audience this SP requires of an assertion. + let audience = entity_id.clone(); + let acs_url = self + .acs_url + .ok_or_else(|| SecurityError::verification("saml2: ACS URL is required"))?; + let idp_metadata = self + .idp_metadata + .ok_or_else(|| SecurityError::verification("saml2: IdP metadata is required"))?; + + let allowed = self + .allowed_signature_algorithms + .unwrap_or_else(default_allowed_algorithms); + + let sp = ServiceProviderBuilder::default() + .entity_id(entity_id.clone()) + .metadata_url(entity_id) + .acs_url(acs_url) + .idp_metadata(idp_metadata) + .allow_idp_initiated(self.allow_idp_initiated) + .allowed_signature_algorithms(Some(allowed)) + .build() + .map_err(|e| SecurityError::verification(format!("saml2: service provider: {e}")))?; + + // Fail closed: an IdP with no signing certificate would make `samael` + // skip signature verification entirely — an authentication bypass. + match sp.idp_signing_certs() { + Ok(Some(certs)) if !certs.is_empty() => {} + _ => { + return Err(SecurityError::verification( + "saml2: asserting party has no signing certificate (refusing to skip \ + signature verification)", + )) + } + } + + // Resolve where to send AuthnRequests (HTTP-Redirect binding). + let sso_redirect_location = self + .sso_redirect_location + .or_else(|| sp.sso_binding_location(HTTP_REDIRECT_BINDING)) + .ok_or_else(|| { + SecurityError::verification("saml2: IdP exposes no HTTP-Redirect SSO endpoint") + })?; + + Ok(RelyingPartyRegistration { + registration_id: self.registration_id, + sp, + sso_redirect_location, + audience, + role_attributes: self.role_attributes, + role_prefix: self.role_prefix, + }) + } +} + +/// A store of [`RelyingPartyRegistration`]s by id — Spring's +/// `RelyingPartyRegistrationRepository`. +pub trait RelyingPartyRegistrationRepository: Send + Sync { + /// Looks up the registration with `registration_id`, if any. + fn find_by_registration_id( + &self, + registration_id: &str, + ) -> Option>; +} + +/// An in-memory [`RelyingPartyRegistrationRepository`]. +#[derive(Default, Clone)] +pub struct InMemoryRelyingPartyRegistrationRepository { + by_id: HashMap>, +} + +impl InMemoryRelyingPartyRegistrationRepository { + /// Builds a repository from the given registrations (keyed by their id). + #[must_use] + pub fn new(registrations: Vec) -> Self { + let by_id = registrations + .into_iter() + .map(|r| (r.registration_id.clone(), Arc::new(r))) + .collect(); + Self { by_id } + } +} + +impl RelyingPartyRegistrationRepository for InMemoryRelyingPartyRegistrationRepository { + fn find_by_registration_id( + &self, + registration_id: &str, + ) -> Option> { + self.by_id.get(registration_id).cloned() + } +} + +/// A store of outgoing `AuthnRequest` IDs with a TTL — Spring's +/// `Saml2AuthenticationRequestRepository`. Lets a later response's +/// `InResponseTo` be validated against a request this SP actually issued. +pub trait Saml2AuthenticationRequestRepository: Send + Sync { + /// Remembers `request_id` for at most `ttl`. + fn save(&self, request_id: &str, ttl: Duration); + /// Reports whether `request_id` is still pending (issued and unexpired). + fn is_pending(&self, request_id: &str) -> bool; + /// Removes `request_id`, returning whether it was pending. + fn remove(&self, request_id: &str) -> bool; +} + +/// An in-memory, TTL'd [`Saml2AuthenticationRequestRepository`]. +#[derive(Default)] +pub struct InMemorySaml2AuthenticationRequestRepository { + pending: Mutex>, +} + +impl InMemorySaml2AuthenticationRequestRepository { + /// Builds an empty repository. + #[must_use] + pub fn new() -> Self { + Self::default() + } +} + +impl Saml2AuthenticationRequestRepository for InMemorySaml2AuthenticationRequestRepository { + fn save(&self, request_id: &str, ttl: Duration) { + let expires_at = SystemTime::now() + ttl; + let mut pending = self.pending.lock().unwrap_or_else(|e| e.into_inner()); + let now = SystemTime::now(); + pending.retain(|_, exp| *exp > now); + pending.insert(request_id.to_string(), expires_at); + } + + fn is_pending(&self, request_id: &str) -> bool { + let pending = self.pending.lock().unwrap_or_else(|e| e.into_inner()); + pending + .get(request_id) + .is_some_and(|exp| *exp > SystemTime::now()) + } + + fn remove(&self, request_id: &str) -> bool { + let mut pending = self.pending.lock().unwrap_or_else(|e| e.into_inner()); + match pending.remove(request_id) { + Some(exp) => exp > SystemTime::now(), + None => false, + } + } +} + +/// One-time-use cache of consumed assertion IDs — the SAML profile's replay +/// protection, which `samael` does not implement. An assertion ID is accepted +/// at most once within its validity window. +pub trait AssertionReplayCache: Send + Sync { + /// Records `assertion_id` as used until `expires_at` (or a bounded default + /// when `None`). Returns `Err` if the id was already recorded and has not + /// yet expired — i.e. a replay. + fn check_and_remember( + &self, + assertion_id: &str, + expires_at: Option, + ) -> Result<(), SecurityError>; +} + +/// An in-memory [`AssertionReplayCache`]. Expired entries are purged lazily on +/// each call, so the map stays bounded by the number of in-flight assertions. +/// +/// **Per-process only:** it cannot detect a replay presented to a *different* +/// instance. A multi-instance / load-balanced deployment should supply a shared +/// (e.g. Redis-backed) [`AssertionReplayCache`] instead — especially with +/// [IdP-initiated SSO](RelyingPartyRegistrationBuilder::allow_idp_initiated), +/// where the cache is the only freshness control. +#[derive(Default)] +pub struct InMemoryAssertionReplayCache { + seen: Mutex>, +} + +impl InMemoryAssertionReplayCache { + /// Builds an empty cache. + #[must_use] + pub fn new() -> Self { + Self::default() + } +} + +impl AssertionReplayCache for InMemoryAssertionReplayCache { + fn check_and_remember( + &self, + assertion_id: &str, + expires_at: Option, + ) -> Result<(), SecurityError> { + let now = SystemTime::now(); + let forget_at = expires_at.unwrap_or(now + REPLAY_FALLBACK_TTL); + let mut seen = self.seen.lock().unwrap_or_else(|e| e.into_inner()); + seen.retain(|_, exp| *exp > now); + if seen.contains_key(assertion_id) { + return Err(SecurityError::verification( + "saml2: assertion replay detected", + )); + } + seen.insert(assertion_id.to_string(), forget_at); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A self-signed RSA test certificate (base64 DER) used as the IdP's signing + /// certificate in the unit tests. + const TEST_IDP_CERT_B64: &str = "MIIDGTCCAgGgAwIBAgIUFH5rQdUdWRLDzj/k+F7hGosU9+swDQYJKoZIhvcNAQELBQAwGzEZMBcGA1UEAwwQRmlyZWZseSBUZXN0IElkUDAgFw0yNjA2MTkyMTM4MjhaGA8yMTI2MDUyNjIxMzgyOFowGzEZMBcGA1UEAwwQRmlyZWZseSBUZXN0IElkUDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANGnXND9uC64r89GMY8I6pjYBeV4x0y83hfQAv0FjQSKdTU9E4NZlHWUtVbpZqjmUhxdI7V0K+8p+Fgr1mtDCNYk86xQ+vXPbLJ70jjDUwM8dh9NxkAPEzf3lR/PkK8cKz+nMwxbbia/Q2R1pZtYRy1xbCDy97skGTex2BjpTEDsR6tTdLk5Pk7wvkigiVr4I+fwbZysM7wt5RTGXAnz7sxyvdrj0BvQvBCVncrYPxLowZdpVFezoBTZa09xlNMv2YCarSjueGCvaQ7YrVk3qD2KOvVHINKz/jjYAooRF/xXtiZR6mNvsUmUoTP6rvyzNGm/VPTC3ZvZbBsuxk8EF7kCAwEAAaNTMFEwHQYDVR0OBBYEFBKpxZhrM9chBpxnSWqsv5sNgeB0MB8GA1UdIwQYMBaAFBKpxZhrM9chBpxnSWqsv5sNgeB0MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAK982nXwgNf5or1phiR+ZOfz2C8jhZGCNxpzcoZ3zGg31VmwENtd7qC5N4vbFFyBSU80dKhxp4kdcQbdCZawRI8zqosvZqvNLFqxUxnzGxbyM7AAHzK10U5t+F8c6WxrEo+d1VfQD27KL7FQ64iBs2qIVglUdwa9vsn18zaQzpYA35Lzpc9vayNHimMcd7REA39VtqL4g0dIcdj1LVtOWa2hQBB68xvMfzW+oz9Z0ymNqAmqEgK/6rztxCz3HQ9sb/k7Fkdso+kL8GlSKkzYFx4DOOIf61lBzK1Q6hSlP4av0DmmHlXKizh56xlCZjWmT00hnl0AZW5iUuPVf2t2ZGE="; + + const IDP_ENTITY_ID: &str = "https://idp.example.com/metadata"; + const IDP_SSO_URL: &str = "https://idp.example.com/sso/redirect"; + const SP_ENTITY_ID: &str = "https://sp.example.com/saml2/metadata"; + const ACS_URL: &str = "https://sp.example.com/login/saml2/sso/test"; + + fn registration() -> RelyingPartyRegistration { + RelyingPartyRegistration::builder("test") + .sp_entity_id(SP_ENTITY_ID) + .assertion_consumer_service_location(ACS_URL) + .asserting_party(IDP_ENTITY_ID, IDP_SSO_URL, TEST_IDP_CERT_B64) + .expect("asserting party") + .role_attribute("groups") + .build() + .expect("registration builds") + } + + #[test] + fn builds_from_asserting_party_details_and_emits_sp_metadata() { + let reg = registration(); + assert_eq!(reg.registration_id(), "test"); + let metadata = reg.metadata_xml().expect("metadata"); + assert!( + metadata.contains(SP_ENTITY_ID), + "SP metadata should advertise the SP entity id: {metadata}" + ); + assert!(metadata.contains("SPSSODescriptor")); + } + + #[test] + fn build_fails_closed_when_idp_has_no_signing_certificate() { + // IdP metadata with an SSO endpoint but NO signing KeyDescriptor. + let no_cert_metadata = format!( + concat!( + "", + "", + "", + "" + ), + eid = IDP_ENTITY_ID, + sso = IDP_SSO_URL, + ); + let result = RelyingPartyRegistration::builder("test") + .sp_entity_id(SP_ENTITY_ID) + .assertion_consumer_service_location(ACS_URL) + .asserting_party_metadata(&no_cert_metadata) + .expect("metadata parses") + .build(); + assert!( + result.is_err(), + "a registration without an IdP signing cert must fail closed" + ); + } + + #[test] + fn build_requires_sp_entity_id_and_acs() { + let missing_entity = RelyingPartyRegistration::builder("test") + .assertion_consumer_service_location(ACS_URL) + .asserting_party(IDP_ENTITY_ID, IDP_SSO_URL, TEST_IDP_CERT_B64) + .expect("asserting party") + .build(); + assert!(missing_entity.is_err()); + } + + #[test] + fn authn_request_redirect_targets_the_idp_with_a_saml_request() { + let reg = registration(); + let redirect = reg + .authn_request_redirect("relay-123") + .expect("authn redirect"); + assert!( + redirect.url.starts_with(IDP_SSO_URL), + "redirect should target the IdP SSO URL: {}", + redirect.url + ); + assert!( + redirect.url.contains("SAMLRequest="), + "redirect must carry a SAMLRequest param: {}", + redirect.url + ); + assert!( + redirect.url.contains("RelayState=relay-123"), + "redirect should carry the relay state: {}", + redirect.url + ); + assert!( + !redirect.request_id.is_empty(), + "a request id must be returned for InResponseTo matching" + ); + } + + #[test] + fn repository_finds_registrations_by_id() { + let repo = InMemoryRelyingPartyRegistrationRepository::new(vec![registration()]); + assert!(repo.find_by_registration_id("test").is_some()); + assert!(repo.find_by_registration_id("nope").is_none()); + } + + #[test] + fn request_repository_tracks_pending_ids_and_consumes_them() { + let repo = InMemorySaml2AuthenticationRequestRepository::new(); + repo.save("id-1", Duration::from_secs(300)); + assert!(repo.is_pending("id-1")); + assert!(!repo.is_pending("id-unknown")); + // remove reports it was pending, and it is gone afterwards. + assert!(repo.remove("id-1")); + assert!(!repo.is_pending("id-1")); + assert!(!repo.remove("id-1")); + } + + #[test] + fn request_repository_expires_ids() { + let repo = InMemorySaml2AuthenticationRequestRepository::new(); + // An already-expired entry is never pending. + repo.save("stale", Duration::from_secs(0)); + assert!(!repo.is_pending("stale")); + assert!(!repo.remove("stale")); + } + + // --- Response handling: verification integration + mapping ------------- + // + // Production verifies an IdP-signed response through `samael`, whose own + // crypto test-suite proves it accepts valid signatures and rejects bad ones + // against the installed xmlsec. These tests cover *this module's* logic: the + // attribute → authorities mapping (on a real `samael` Assertion), one-time-use + // replay protection, and that an unsigned response is rejected end to end. + + use samael::crypto::{decode_x509_cert, CertificateDer}; + use samael::idp::response_builder::{build_response_template, ResponseAttribute}; + use samael::idp::sp_extractor::RequiredAttribute; + use samael::schema::Response; + use samael::traits::ToXml; + + fn b64() -> base64::engine::general_purpose::GeneralPurpose { + base64::engine::general_purpose::STANDARD + } + + fn idp_cert() -> CertificateDer { + decode_x509_cert(TEST_IDP_CERT_B64).expect("decode test IdP cert") + } + + /// Builds an IdP response carrying the given `groups` attribute values (with + /// only the empty signature template — it is never signed). + fn build_response( + name_id: &str, + audience: &str, + request_id: &str, + groups: &[&str], + ) -> Response { + let cert = idp_cert(); + let attrs: Vec = groups + .iter() + .map(|v| ResponseAttribute { + required_attribute: RequiredAttribute { + name: "groups".to_string(), + format: None, + }, + value: v, + }) + .collect(); + build_response_template( + &cert, + name_id, + audience, + IDP_ENTITY_ID, + ACS_URL, + request_id, + &attrs, + ) + } + + #[test] + fn maps_a_verified_assertion_to_authentication() { + // `map_assertion` runs on the Assertion `samael` returns from a verified + // response; build a structurally-identical one and map it. + let reg = registration(); + let response = build_response( + "alice@example.com", + SP_ENTITY_ID, + "id-req", + &["admins", "users"], + ); + let assertion = response.assertion.expect("assertion present"); + let auth = reg.map_assertion(assertion); + assert_eq!(auth.principal, "alice@example.com"); + assert_eq!(auth.username, "alice@example.com"); + // Each configured role-attribute value → ROLE_; has_role matches + // the prefixed authority. Every attribute is also exposed in claims. + assert!(auth.has_role("admins")); + assert!(auth.has_role("users")); + assert!(auth.claims.contains_key("groups")); + } + + #[test] + fn maps_no_roles_when_no_role_attribute_is_configured() { + // A registration without a role_attribute grants no roles, but still + // exposes the attributes as claims. + let reg = RelyingPartyRegistration::builder("test") + .sp_entity_id(SP_ENTITY_ID) + .assertion_consumer_service_location(ACS_URL) + .asserting_party(IDP_ENTITY_ID, IDP_SSO_URL, TEST_IDP_CERT_B64) + .expect("asserting party") + .build() + .expect("registration"); + let response = build_response("bob@example.com", SP_ENTITY_ID, "id-req", &["admins"]); + let auth = reg.map_assertion(response.assertion.expect("assertion")); + assert_eq!(auth.principal, "bob@example.com"); + assert!(auth.roles.is_empty()); + assert!(auth.claims.contains_key("groups")); + } + + #[test] + fn rejects_an_unsigned_response() { + // End to end: an unsigned response is rejected by the verification path + // (samael finds no valid signature for the configured IdP cert). + let reg = registration(); + let request_id = "id-req-unsigned"; + let xml = build_response("alice@example.com", SP_ENTITY_ID, request_id, &["users"]) + .to_string() + .expect("serialize response"); + let resp_b64 = b64().encode(xml.as_bytes()); + let replay = InMemoryAssertionReplayCache::new(); + assert!( + reg.authenticate(&resp_b64, &[request_id], &replay).is_err(), + "an unsigned response must be rejected" + ); + } + + #[test] + fn rejects_non_base64_and_oversized_responses() { + let reg = registration(); + let replay = InMemoryAssertionReplayCache::new(); + // Not valid base64. + assert!(reg + .authenticate("@@@not base64@@@", &["id"], &replay) + .is_err()); + // Oversized input is rejected before allocating the decoded buffer. + let huge = "A".repeat(11 * 1024 * 1024); + assert!(reg.authenticate(&huge, &["id"], &replay).is_err()); + } + + #[test] + fn replay_cache_enforces_one_time_use() { + let cache = InMemoryAssertionReplayCache::new(); + // First use of an assertion id is accepted; the second is a replay. + assert!(cache.check_and_remember("assertion-1", None).is_ok()); + assert!(cache.check_and_remember("assertion-1", None).is_err()); + // A different id is independent. + assert!(cache.check_and_remember("assertion-2", None).is_ok()); + // An already-expired entry never blocks a later use. + let past = std::time::SystemTime::now() - std::time::Duration::from_secs(1); + assert!(cache.check_and_remember("assertion-3", Some(past)).is_ok()); + assert!(cache.check_and_remember("assertion-3", Some(past)).is_ok()); + } + + #[test] + fn audience_restriction_is_enforced_fail_closed() { + // A matching audience is accepted. + let matching = build_response("alice@example.com", SP_ENTITY_ID, "id", &[]) + .assertion + .unwrap(); + assert!(audience_includes(&matching, SP_ENTITY_ID)); + // A different SP's audience is rejected. + let other = build_response( + "alice@example.com", + "https://other-sp.example/metadata", + "id", + &[], + ) + .assertion + .unwrap(); + assert!(!audience_includes(&other, SP_ENTITY_ID)); + // No AudienceRestriction at all → fail closed (samael would skip it). + let mut absent = build_response("alice@example.com", SP_ENTITY_ID, "id", &[]) + .assertion + .unwrap(); + absent.conditions = None; + assert!(!audience_includes(&absent, SP_ENTITY_ID)); + } + + #[test] + fn missing_name_id_maps_to_an_anonymous_principal() { + // An assertion with no Subject/NameID maps to an empty principal, which + // is not authenticated (authenticate() rejects it before returning Ok). + let reg = registration(); + let mut assertion = build_response("alice@example.com", SP_ENTITY_ID, "id", &["users"]) + .assertion + .unwrap(); + assertion.subject = None; + let auth = reg.map_assertion(assertion); + assert!(auth.principal.is_empty()); + assert!(!auth.is_authenticated()); + } + + #[test] + fn duplicate_attribute_blocks_merge_into_claims() { + // `&["admins", "users"]` produces two blocks; + // both contribute roles AND both values survive in claims (not last-wins). + let reg = registration(); + let assertion = build_response( + "alice@example.com", + SP_ENTITY_ID, + "id", + &["admins", "users"], + ) + .assertion + .unwrap(); + let auth = reg.map_assertion(assertion); + assert!(auth.has_role("admins")); + assert!(auth.has_role("users")); + let groups = auth + .claims + .get("groups") + .and_then(|v| v.as_array()) + .expect("groups claim is an array"); + assert_eq!( + groups.len(), + 2, + "both attribute blocks must survive in claims" + ); + } +} diff --git a/docs/book/dist/firefly-rust-by-example-es.epub b/docs/book/dist/firefly-rust-by-example-es.epub index 83b04a7..6d1e933 100644 Binary files a/docs/book/dist/firefly-rust-by-example-es.epub and b/docs/book/dist/firefly-rust-by-example-es.epub differ diff --git a/docs/book/dist/firefly-rust-by-example-es.pdf b/docs/book/dist/firefly-rust-by-example-es.pdf index 3fd34d4..3e84629 100644 Binary files a/docs/book/dist/firefly-rust-by-example-es.pdf and b/docs/book/dist/firefly-rust-by-example-es.pdf differ diff --git a/docs/book/dist/firefly-rust-by-example.epub b/docs/book/dist/firefly-rust-by-example.epub index 9329bd9..1575445 100644 Binary files a/docs/book/dist/firefly-rust-by-example.epub and b/docs/book/dist/firefly-rust-by-example.epub differ diff --git a/docs/book/dist/firefly-rust-by-example.pdf b/docs/book/dist/firefly-rust-by-example.pdf index 5251a73..4fac110 100644 Binary files a/docs/book/dist/firefly-rust-by-example.pdf and b/docs/book/dist/firefly-rust-by-example.pdf differ diff --git a/docs/book/src-es/14a-spring-security-parity.md b/docs/book/src-es/14a-spring-security-parity.md index 35ce16a..c15190b 100644 --- a/docs/book/src-es/14a-spring-security-parity.md +++ b/docs/book/src-es/14a-spring-security-parity.md @@ -48,7 +48,7 @@ En la columna **Estado**, :status-supported: indica una función soportada, | Servidor de autorización | :status-partial: | `AuthorizationServer` (client-credentials + refresh-token) montado vía `AuthorizationServerRouter` (`/oauth2/token`, metadatos RFC 8414); el grant authorization_code del lado servidor en la hoja de ruta | | Autenticación LDAP / Active Directory | :status-partial: | Módulo `ldap` opcional: `LdapAuthenticationProvider` (bind auth + autoridades de grupo) + `ActiveDirectoryLdapAuthenticationProvider`, sobre `ldap3` (`ldapAuthentication()`) | | ACL / seguridad de objetos de dominio | :status-supported: | `Acl` / `AccessControlEntry` / `Permission` / `Sid` / `ObjectIdentity`, `AclService` + `InMemoryAclService`, y `AclPermissionEvaluator` que conecta `hasPermission(...)` con ACLs por objeto (`spring-security-acl`) | -| SAML2 (`saml2Login()`) | :status-planned: | Implementación del lado SP (`RelyingPartyRegistration` + verificación de respuestas firmadas) tras una característica opcional; el *release* depende de una pila XML-Security de Rust estable | +| SAML2 (`saml2Login()`) | :status-partial: | Módulo `saml2` opcional: `RelyingPartyRegistration` del lado SP, `AuthnRequest` iniciado por el SP, y verificación de respuestas firmadas (`OpenSaml4AuthenticationProvider`) con anti-repetición de un solo uso, sobre `samael` (quedan SLO / `AuthnRequest` firmado / aserciones cifradas) | ## Comportamientos fieles a Spring que conviene conocer @@ -246,6 +246,44 @@ que un *deny* colocado antes de un *grant* tiene prioridad (la acotado, así que una cadena de padres cíclica o demasiado profunda termina (y deniega) en vez de quedar en bucle. +## Inicio de sesión único SAML2 + +El módulo `saml2` opcional (`--features saml2`) es el lado *Service Provider* +del perfil SAML 2.0 Web-Browser-SSO — el `saml2Login()` de Spring. La +verificación de firma XML, la canonicalización y las comprobaciones del perfil +SAML (audiencia, *recipient*, `InResponseTo`, *status*, condiciones temporales) +se delegan en la *crate* [`samael`] (que enlaza la pila probada +`xmlsec`/`libxml2`/OpenSSL); este módulo es el envoltorio fiel a Spring y +endurecido: + +- **`RelyingPartyRegistration`** (+ `InMemoryRelyingPartyRegistrationRepository`) + — una relación SP↔IdP, configurada desde metadatos del IdP o detalles + explícitos. +- **`AuthnRequest` iniciado por el SP** — `authn_request_redirect` construye la + URL de *binding* HTTP-Redirect y devuelve el `ID` de la petición a recordar + (`Saml2AuthenticationRequestRepository`). +- **`authenticate`** — verifica una respuesta *binding* POST y mapea el `NameID` + (y atributos configurados) a una `Authentication` + (el `OpenSaml4AuthenticationProvider` de Spring). +- **Metadatos del SP** — `metadata_xml` (el `Saml2MetadataFilter` de Spring). + +Endurecimiento sobre `samael`: + +- **Falla en cerrado si falta el certificado de firma del IdP** — sin él + `samael` omitiría la verificación de firma (un *bypass* de autenticación). +- **Lista de algoritmos de firma permitidos**, fijada a RSA/ECDSA-SHA256+ + (`samael` acepta *todos* por defecto — riesgo de sustitución de algoritmo). +- **Anti-repetición de un solo uso** (`AssertionReplayCache`) — el perfil SAML + la requiere pero `samael` no la rastrea. +- Todas las llamadas a la pila nativa XML-Security se **serializan** (no es + segura para concurrencia). + +El *single-logout*, los `AuthnRequest` firmados y las aserciones cifradas quedan +en la hoja de ruta. (`saml2` enlaza `libxml2` + `xmlsec1` + OpenSSL del sistema; +la compilación por defecto no se ve afectada.) + +[`samael`]: https://crates.io/crates/samael + ## Hoja de ruta La paridad se entrega por niveles, cada uno un incremento: @@ -268,6 +306,7 @@ La paridad se entrega por niveles, cada uno un incremento: 6. **Subsistemas grandes** — entregados de uno en uno (opcional). **LDAP / Active Directory (hecho)** — el módulo `ldap` opcional. **ACL / seguridad de objetos de dominio (hecho)** — paridad con `spring-security-acl`, en Rust puro. - **SAML2** tiene una implementación del lado SP (registro + verificación de - respuestas firmadas) tras una característica opcional, con *release* pendiente - de una pila XML-Security de Rust estable. + **SSO SAML2 (hecho)** — el módulo `saml2` opcional: registro del SP, + `AuthnRequest` iniciado por el SP y verificación de respuestas firmadas con + anti-repetición (quedan como seguimiento el *single-logout*, los + `AuthnRequest` firmados y las aserciones cifradas). diff --git a/docs/book/src/14a-spring-security-parity.md b/docs/book/src/14a-spring-security-parity.md index 9cf2726..6d4d875 100644 --- a/docs/book/src/14a-spring-security-parity.md +++ b/docs/book/src/14a-spring-security-parity.md @@ -47,7 +47,7 @@ In the **Status** column, :status-supported: marks a supported feature, | Authorization server | :status-partial: | `AuthorizationServer` (client-credentials + refresh-token) mounted via `AuthorizationServerRouter` (`/oauth2/token`, RFC 8414 metadata); server-side authorization_code grant on the roadmap | | LDAP / Active Directory authentication | :status-partial: | Feature-gated `ldap`: `LdapAuthenticationProvider` (bind auth + group authorities) + `ActiveDirectoryLdapAuthenticationProvider`, over `ldap3` (`ldapAuthentication()`) | | ACL / domain-object security | :status-supported: | `Acl` / `AccessControlEntry` / `Permission` / `Sid` / `ObjectIdentity`, `AclService` + `InMemoryAclService`, and `AclPermissionEvaluator` wiring `hasPermission(...)` to per-object ACLs (`spring-security-acl`) | -| SAML2 (`saml2Login()`) | :status-planned: | SP-side `RelyingPartyRegistration` + signed-response verification implemented behind an opt-in feature; release pending a stable Rust XML-Security stack | +| SAML2 (`saml2Login()`) | :status-partial: | Feature-gated `saml2`: SP-side `RelyingPartyRegistration`, SP-initiated `AuthnRequest` redirect, and signed-response verification (`OpenSaml4AuthenticationProvider`) with one-time-use replay, over `samael` (SLO / signed-AuthnRequest / encrypted-assertions remain) | ## Spring-faithful behaviours to know @@ -234,6 +234,45 @@ precedence (Spring's `DefaultPermissionGrantingStrategy`). The inheritance walk is bounded, so a cyclic or pathologically deep parent chain terminates (and denies) rather than looping. +## SAML2 single sign-on + +The feature-gated `saml2` module (opt-in: `--features saml2`) is the +Service-Provider side of the SAML 2.0 Web-Browser-SSO profile — Spring's +`saml2Login()`. The XML-signature verification, canonicalization, and SAML +profile checks (audience, recipient, `InResponseTo`, status, time conditions) +are delegated to the [`samael`] crate (which links the battle-tested +`xmlsec`/`libxml2`/OpenSSL stack); this module is the Spring-faithful, hardened +wrapper: + +- **`RelyingPartyRegistration`** (+ `InMemoryRelyingPartyRegistrationRepository`) + — one SP↔IdP relationship, configured from IdP metadata or explicit + asserting-party details (Spring's `RelyingPartyRegistration`). +- **SP-initiated `AuthnRequest`** — `authn_request_redirect` builds the + HTTP-Redirect-binding URL and returns the request `ID` to remember + (`Saml2AuthenticationRequestRepository`). +- **`authenticate`** — verifies a POST-binding response and maps the `NameID` + (and configured attributes) to an `Authentication` (Spring's + `OpenSaml4AuthenticationProvider`). +- **SP metadata** — `metadata_xml` (Spring's `Saml2MetadataFilter`). + +Hardening on top of `samael`: + +- **Fail-closed on a missing IdP signing certificate** — without one `samael` + would skip signature verification entirely (an authentication bypass), so + building a registration refuses it. +- **Signature-algorithm allow-list** pinned to SHA-256+ RSA/ECDSA (`samael` + otherwise accepts *all* algorithms — an algorithm-substitution risk). +- **One-time-use replay protection** (`AssertionReplayCache`) — the SAML + profile requires it but `samael` does not track it. +- All native XML-Security calls are **serialized** (the stack is not + concurrency-safe). + +Single-logout, signed `AuthnRequest`s, and encrypted assertions remain on the +roadmap. (`saml2` links a system `libxml2` + `xmlsec1` + OpenSSL; the default +build is unaffected.) + +[`samael`]: https://crates.io/crates/samael + ## Roadmap Parity is delivered in tiers, each its own increment: @@ -255,6 +294,7 @@ Parity is delivered in tiers, each its own increment: 6. **Big subsystems** — delivered one opt-in subsystem at a time. **LDAP / Active Directory (done)** — the feature-gated `ldap` module. **ACL / domain-object security (done)** — `spring-security-acl` parity, pure Rust. - **SAML2** has an SP-side implementation (registration + signed-response - verification) behind an opt-in feature, with release pending a stable Rust - XML-Security stack. + **SAML2 SSO (done)** — the feature-gated `saml2` module: SP registration, + SP-initiated `AuthnRequest`, and signed-response verification with replay + protection (single-logout, signed `AuthnRequest`s, and encrypted assertions + remain follow-ups).