diff --git a/CHANGELOG.md b/CHANGELOG.md index 9aedafd9..2b70771b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,78 @@ All notable changes to the Firefly Framework for Rust. +## v26.6.29 — 2026-06-18 + +A **Spring Security 6 parity** increment (Tier 0): an adversarially-verified +audit of the security tier against Spring Security 6 / Spring Boot 3, followed +by the hardening pass that closes the silent semantic divergences in shipping +code, plus the two Spring Security 6.4 passwordless mechanisms. See the new +**Spring Security Parity** book appendix for the full coverage matrix. + +### Added + +- **One-time-token (magic-link) login** — Spring 6.4 `oneTimeTokenLogin()`: + `OneTimeTokenService` (single-use, expiring; in-memory impl) + + `OneTimeTokenGenerationSuccessHandler` for out-of-band delivery + + `ott_login_routes` (`POST /ott/generate`, `GET /login/ott`) that redeems a + token, rotates the session id, and establishes the security context. +- **WebAuthn / passkeys** — Spring 6.4 `webAuthn()`: a feature-gated `webauthn` + module with the registration and authentication ceremonies over `webauthn-rs` + and a pluggable credential repository (opt-in; off by default). +- **EC + EdDSA JWKS keys** — `JwksVerifier` now verifies `ES256`/`ES384` and + `EdDSA` tokens in addition to RSA (`RS*`/`PS*`). +- **`FilterChain::try_layer`** / **`CorsLayer::try_new`** — fallible builders + that surface invalid glob patterns / unsafe CORS config as a recoverable + error instead of panicking at startup. +- **Configurable clock-skew** (`clock_skew_seconds`, default 60s) and **`nbf` + validation** on `JwksVerifier` and `JwtService`. +- A **Spring Security Parity** appendix in the book (EN + ES). + +### Changed (Spring-faithful defaults — each with an escape hatch) + +- **Method security works behind every authentication mechanism.** + `SessionAuthenticationLayer` now scopes the task-local security context, so + `#[pre_authorize]` / `current_authentication()` work for session- and + OAuth2-login-authenticated callers (previously bearer-only). +- **`hasRole('X')` matches the `ROLE_X` authority** (Spring's prefix) as well as + a bare role name. +- **HSTS is sent only over secure requests** by default + (`hsts_include_insecure` to force it). +- **The CSRF cookie is `Secure` only when the request is secure** + (`CookieSecure::{Auto,Always,Never}`, default `Auto`). +- **A wildcard CORS origin with `allow_credentials` is rejected** at + construction. +- **JWT/JWKS validation tolerates 60s clock skew** (was zero). +- **Path-prefix authorization is segment-aware** — `permit("/api")` no longer + matches `/api-internal`. + +### Fixed (security) + +- **OIDC `id_token` is never trusted without validation** — the login fails if + it cannot be verified, instead of silently falling through to userinfo. +- **Bearer rejections carry an RFC 6750 `WWW-Authenticate: Bearer` challenge** + (`error="invalid_token"` when a token was supplied). +- **No user-enumeration timing oracle** — an unknown username runs comparable + bcrypt work to a wrong password (internal-db IdP). +- **Postgres `SessionRegistry` rows expire** (opt-in absolute TTL + pruning, via + `with_ttl`) so an orphaned session can no longer inflate the per-principal + concurrency count. Pruning is **off by default** — a fixed TTL would wrongly + evict still-active *sliding* sessions, so enable it only with an absolute + session lifetime. + +### Known limitations (roadmap) + +- `request_is_secure` trusts `X-Forwarded-Proto` from any caller; deploy behind a + trusted proxy (or terminate TLS in-process, which Firefly marks automatically). + A trusted-proxy allowlist is planned. +- WebAuthn `authenticate/options` reveals whether a username has registered + passkeys; use discoverable (usernameless) credentials to avoid enumeration. +- Sliding-session expiry isn't synced into the distributed `SessionRegistry` + (no `HttpSessionEventPublisher` analog yet) — deregister on logout or set an + absolute TTL. +- One-time-token magic links are redeemed via `GET` (token in the URL); + single-use + short expiry mitigate referer leakage. + ## v26.6.28 — 2026-06-16 A Spring Boot **parity** increment: the declarative HTTP-interface client — the diff --git a/Cargo.lock b/Cargo.lock index bd4b76a3..4dcec9da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -199,13 +199,29 @@ dependencies = [ "password-hash", ] +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive 0.5.1", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "asn1-rs" version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" dependencies = [ - "asn1-rs-derive", + "asn1-rs-derive 0.6.0", "asn1-rs-impl", "displaydoc", "nom 7.1.3", @@ -215,6 +231,18 @@ dependencies = [ "time", ] +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "asn1-rs-derive" version = "0.6.0" @@ -449,7 +477,7 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ "async-trait", "axum-core", - "base64", + "base64 0.22.1", "bytes", "futures-util", "http", @@ -531,6 +559,12 @@ dependencies = [ "fastrand 2.4.1", ] +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -543,13 +577,24 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "base64urlsafedata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b08e33815c87d8cadcddb1e74ac307368a3751fbe40c961538afa21a1899f21c" +dependencies = [ + "base64 0.21.7", + "pastey", + "serde", +] + [[package]] name = "bcrypt" version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e65938ed058ef47d92cf8b346cc76ef48984572ade631927e9937b5ffc7662c7" dependencies = [ - "base64", + "base64 0.22.1", "blowfish", "getrandom 0.2.17", "subtle", @@ -649,7 +694,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7969a9ba84b0ff843813e7249eed1678d9b6607ce5a3b8f0a47af3fcf7978e6e" dependencies = [ "ahash", - "base64", + "base64 0.22.1", "bitvec", "getrandom 0.2.17", "getrandom 0.3.4", @@ -1157,13 +1202,27 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs 0.6.2", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "der-parser" version = "10.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" dependencies = [ - "asn1-rs", + "asn1-rs 0.7.2", "displaydoc", "nom 7.1.3", "num-bigint", @@ -1313,7 +1372,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" dependencies = [ - "base64", + "base64 0.22.1", "memchr", ] @@ -1436,7 +1495,7 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "firefly" -version = "26.6.28" +version = "26.6.29" dependencies = [ "axum", "firefly-actuator", @@ -1480,7 +1539,7 @@ dependencies = [ [[package]] name = "firefly-actuator" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "axum", @@ -1497,7 +1556,7 @@ dependencies = [ [[package]] name = "firefly-admin" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "axum", @@ -1526,7 +1585,7 @@ dependencies = [ [[package]] name = "firefly-aop" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "inventory", @@ -1536,7 +1595,7 @@ dependencies = [ [[package]] name = "firefly-backoffice" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "axum", @@ -1555,7 +1614,7 @@ dependencies = [ [[package]] name = "firefly-cache" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "firefly-observability", @@ -1567,7 +1626,7 @@ dependencies = [ [[package]] name = "firefly-cache-postgres" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "chrono", @@ -1579,7 +1638,7 @@ dependencies = [ [[package]] name = "firefly-cache-redis" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "firefly-cache", @@ -1591,11 +1650,11 @@ dependencies = [ [[package]] name = "firefly-callbacks" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "axum", - "base64", + "base64 0.22.1", "chrono", "firefly-client", "firefly-kernel", @@ -1617,7 +1676,7 @@ dependencies = [ [[package]] name = "firefly-cli" -version = "26.6.28" +version = "26.6.29" dependencies = [ "axum", "chrono", @@ -1638,7 +1697,7 @@ dependencies = [ [[package]] name = "firefly-client" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-stream", "axum", @@ -1660,7 +1719,7 @@ dependencies = [ [[package]] name = "firefly-config" -version = "26.6.28" +version = "26.6.29" dependencies = [ "axum", "regex", @@ -1675,7 +1734,7 @@ dependencies = [ [[package]] name = "firefly-config-server" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "axum", @@ -1691,7 +1750,7 @@ dependencies = [ [[package]] name = "firefly-container" -version = "26.6.28" +version = "26.6.29" dependencies = [ "futures", "inventory", @@ -1702,7 +1761,7 @@ dependencies = [ [[package]] name = "firefly-cqrs" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "chrono", @@ -1725,7 +1784,7 @@ dependencies = [ [[package]] name = "firefly-data" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-stream", "async-trait", @@ -1742,7 +1801,7 @@ dependencies = [ [[package]] name = "firefly-data-mongodb" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-stream", "async-trait", @@ -1760,7 +1819,7 @@ dependencies = [ [[package]] name = "firefly-data-sqlx" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-stream", "async-trait", @@ -1782,7 +1841,7 @@ dependencies = [ [[package]] name = "firefly-ecm" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "chrono", @@ -1798,7 +1857,7 @@ dependencies = [ [[package]] name = "firefly-ecm-esignature-adobe-sign" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "axum", @@ -1815,7 +1874,7 @@ dependencies = [ [[package]] name = "firefly-ecm-esignature-docusign" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "axum", @@ -1832,7 +1891,7 @@ dependencies = [ [[package]] name = "firefly-ecm-esignature-logalty" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "axum", @@ -1849,7 +1908,7 @@ dependencies = [ [[package]] name = "firefly-ecm-storage-aws" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "axum", @@ -1869,11 +1928,11 @@ dependencies = [ [[package]] name = "firefly-ecm-storage-azure" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "axum", - "base64", + "base64 0.22.1", "chrono", "firefly-ecm", "futures", @@ -1889,10 +1948,10 @@ dependencies = [ [[package]] name = "firefly-eda" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "chrono", "firefly-container", "firefly-kernel", @@ -1911,7 +1970,7 @@ dependencies = [ [[package]] name = "firefly-eda-kafka" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "firefly-eda", @@ -1926,7 +1985,7 @@ dependencies = [ [[package]] name = "firefly-eda-postgres" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "chrono", @@ -1944,7 +2003,7 @@ dependencies = [ [[package]] name = "firefly-eda-rabbitmq" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "firefly-eda", @@ -1959,7 +2018,7 @@ dependencies = [ [[package]] name = "firefly-eda-redis" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "firefly-eda", @@ -1975,10 +2034,10 @@ dependencies = [ [[package]] name = "firefly-eventsourcing" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "chrono", "firefly-eda", "firefly-transactional", @@ -1993,7 +2052,7 @@ dependencies = [ [[package]] name = "firefly-i18n" -version = "26.6.28" +version = "26.6.29" dependencies = [ "axum", "http", @@ -2008,7 +2067,7 @@ dependencies = [ [[package]] name = "firefly-idp" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "axum", @@ -2024,11 +2083,11 @@ dependencies = [ [[package]] name = "firefly-idp-aws-cognito" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "axum", - "base64", + "base64 0.22.1", "chrono", "firefly-idp", "futures", @@ -2045,7 +2104,7 @@ dependencies = [ [[package]] name = "firefly-idp-azure-ad" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "axum", @@ -2062,11 +2121,11 @@ dependencies = [ [[package]] name = "firefly-idp-internal-db" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "axum", - "base64", + "base64 0.22.1", "bcrypt", "chrono", "firefly-idp", @@ -2084,7 +2143,7 @@ dependencies = [ [[package]] name = "firefly-idp-keycloak" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "axum", @@ -2101,7 +2160,7 @@ dependencies = [ [[package]] name = "firefly-integration-tests" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "axum", @@ -2136,7 +2195,7 @@ dependencies = [ [[package]] name = "firefly-kernel" -version = "26.6.28" +version = "26.6.29" dependencies = [ "chrono", "serde", @@ -2148,7 +2207,7 @@ dependencies = [ [[package]] name = "firefly-lifecycle" -version = "26.6.28" +version = "26.6.29" dependencies = [ "thiserror 1.0.69", "tokio", @@ -2157,7 +2216,7 @@ dependencies = [ [[package]] name = "firefly-macros" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "axum", @@ -2181,7 +2240,7 @@ dependencies = [ [[package]] name = "firefly-migrations" -version = "26.6.28" +version = "26.6.29" dependencies = [ "chrono", "hex", @@ -2194,7 +2253,7 @@ dependencies = [ [[package]] name = "firefly-notifications" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "chrono", @@ -2209,7 +2268,7 @@ dependencies = [ [[package]] name = "firefly-notifications-firebase" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "axum", @@ -2225,11 +2284,11 @@ dependencies = [ [[package]] name = "firefly-notifications-resend" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "axum", - "base64", + "base64 0.22.1", "firefly-notifications", "futures", "reqwest", @@ -2242,11 +2301,11 @@ dependencies = [ [[package]] name = "firefly-notifications-sendgrid" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "axum", - "base64", + "base64 0.22.1", "firefly-notifications", "futures", "reqwest", @@ -2259,10 +2318,10 @@ dependencies = [ [[package]] name = "firefly-notifications-smtp" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "firefly-notifications", "futures", "lettre", @@ -2276,7 +2335,7 @@ dependencies = [ [[package]] name = "firefly-notifications-twilio" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "axum", @@ -2292,7 +2351,7 @@ dependencies = [ [[package]] name = "firefly-observability" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "chrono", @@ -2316,7 +2375,7 @@ dependencies = [ [[package]] name = "firefly-openapi" -version = "26.6.28" +version = "26.6.29" dependencies = [ "axum", "chrono", @@ -2332,7 +2391,7 @@ dependencies = [ [[package]] name = "firefly-orchestration" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "axum", @@ -2356,7 +2415,7 @@ dependencies = [ [[package]] name = "firefly-plugins" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "chrono", @@ -2366,7 +2425,7 @@ dependencies = [ [[package]] name = "firefly-reactive" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-stream", "firefly-kernel", @@ -2378,7 +2437,7 @@ dependencies = [ [[package]] name = "firefly-resilience" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "firefly-config", @@ -2390,7 +2449,7 @@ dependencies = [ [[package]] name = "firefly-rule-engine" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "axum", @@ -2410,7 +2469,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "axum", @@ -2427,7 +2486,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-core" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "chrono", @@ -2442,7 +2501,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-interfaces" -version = "26.6.28" +version = "26.6.29" dependencies = [ "chrono", "firefly", @@ -2453,7 +2512,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-models" -version = "26.6.28" +version = "26.6.29" dependencies = [ "chrono", "firefly", @@ -2466,7 +2525,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-sdk" -version = "26.6.28" +version = "26.6.29" dependencies = [ "firefly-client", "firefly-sample-lumen-ledger-interfaces", @@ -2478,7 +2537,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-web" -version = "26.6.28" +version = "26.6.29" dependencies = [ "axum", "firefly", @@ -2496,7 +2555,7 @@ dependencies = [ [[package]] name = "firefly-sample-macro-quickstart" -version = "26.6.28" +version = "26.6.29" dependencies = [ "axum", "firefly", @@ -2509,7 +2568,7 @@ dependencies = [ [[package]] name = "firefly-sample-orders" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "axum", @@ -2532,7 +2591,7 @@ dependencies = [ [[package]] name = "firefly-sample-reactive-banking" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-stream", "async-trait", @@ -2572,7 +2631,7 @@ dependencies = [ [[package]] name = "firefly-scheduling" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "chrono", @@ -2593,12 +2652,12 @@ dependencies = [ [[package]] name = "firefly-security" -version = "26.6.28" +version = "26.6.29" dependencies = [ "argon2", "async-trait", "axum", - "base64", + "base64 0.22.1", "bcrypt", "firefly-session", "globset", @@ -2615,15 +2674,19 @@ dependencies = [ "tokio", "tokio-postgres", "tower 0.5.3", + "tracing", + "uuid", + "webauthn-authenticator-rs", + "webauthn-rs", ] [[package]] name = "firefly-session" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "axum", - "base64", + "base64 0.22.1", "chrono", "firefly-cache", "futures", @@ -2644,7 +2707,7 @@ dependencies = [ [[package]] name = "firefly-session-mongodb" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "firefly-session", @@ -2657,7 +2720,7 @@ dependencies = [ [[package]] name = "firefly-session-postgres" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "firefly-session", @@ -2669,7 +2732,7 @@ dependencies = [ [[package]] name = "firefly-session-redis" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "firefly-session", @@ -2681,7 +2744,7 @@ dependencies = [ [[package]] name = "firefly-shell" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "futures", @@ -2691,7 +2754,7 @@ dependencies = [ [[package]] name = "firefly-spike-linkspike-core" -version = "26.6.28" +version = "26.6.29" dependencies = [ "axum", "firefly", @@ -2699,7 +2762,7 @@ dependencies = [ [[package]] name = "firefly-spike-linkspike-web" -version = "26.6.28" +version = "26.6.29" dependencies = [ "axum", "firefly", @@ -2711,7 +2774,7 @@ dependencies = [ [[package]] name = "firefly-sse" -version = "26.6.28" +version = "26.6.29" dependencies = [ "axum", "bytes", @@ -2727,7 +2790,7 @@ dependencies = [ [[package]] name = "firefly-starter-application" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "firefly-cqrs", @@ -2741,7 +2804,7 @@ dependencies = [ [[package]] name = "firefly-starter-core" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "axum", @@ -2767,7 +2830,7 @@ dependencies = [ [[package]] name = "firefly-starter-data" -version = "26.6.28" +version = "26.6.29" dependencies = [ "axum", "firefly-cqrs", @@ -2780,7 +2843,7 @@ dependencies = [ [[package]] name = "firefly-starter-domain" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "firefly-eventsourcing", @@ -2792,7 +2855,7 @@ dependencies = [ [[package]] name = "firefly-starter-experience" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "axum", @@ -2813,7 +2876,7 @@ dependencies = [ [[package]] name = "firefly-starter-web" -version = "26.6.28" +version = "26.6.29" dependencies = [ "axum", "firefly-kernel", @@ -2828,10 +2891,10 @@ dependencies = [ [[package]] name = "firefly-testkit" -version = "26.6.28" +version = "26.6.29" dependencies = [ "axum", - "base64", + "base64 0.22.1", "firefly-config", "firefly-container", "hex", @@ -2847,7 +2910,7 @@ dependencies = [ [[package]] name = "firefly-transactional" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "inventory", @@ -2858,10 +2921,10 @@ dependencies = [ [[package]] name = "firefly-utils" -version = "26.6.28" +version = "26.6.29" dependencies = [ "aes-gcm", - "base64", + "base64 0.22.1", "rand 0.8.6", "serde", "serde_json", @@ -2874,7 +2937,7 @@ dependencies = [ [[package]] name = "firefly-validators" -version = "26.6.28" +version = "26.6.29" dependencies = [ "chrono", "firefly-kernel", @@ -2885,12 +2948,12 @@ dependencies = [ [[package]] name = "firefly-web" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "axum", "axum-server", - "base64", + "base64 0.22.1", "bytes", "chrono", "firefly-container", @@ -2924,11 +2987,11 @@ dependencies = [ [[package]] name = "firefly-webhooks" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "axum", - "base64", + "base64 0.22.1", "chrono", "firefly-client", "firefly-kernel", @@ -2952,7 +3015,7 @@ dependencies = [ [[package]] name = "firefly-websocket" -version = "26.6.28" +version = "26.6.29" dependencies = [ "async-trait", "axum", @@ -3271,6 +3334,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -3594,7 +3668,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -3951,7 +4025,7 @@ version = "9.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" dependencies = [ - "base64", + "base64 0.22.1", "js-sys", "pem", "ring", @@ -4004,7 +4078,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "email-encoding", "email_address", "fastrand 2.4.1", @@ -4274,7 +4348,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "276ba0cd571553d1f6936c6f180964776ece6ab7507dc8765f8a9c9c49d8cd00" dependencies = [ - "base64", + "base64 0.22.1", "bitflags 2.13.0", "bson", "derive-where", @@ -4435,6 +4509,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -4505,13 +4590,22 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs 0.6.2", +] + [[package]] name = "oid-registry" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" dependencies = [ - "asn1-rs", + "asn1-rs 0.7.2", ] [[package]] @@ -4604,7 +4698,7 @@ dependencies = [ "sha1", "sha2 0.10.9", "thiserror 2.0.18", - "x509-parser", + "x509-parser 0.17.0", ] [[package]] @@ -4647,6 +4741,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pbkdf2" version = "0.12.2" @@ -4663,7 +4763,7 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] @@ -4885,7 +4985,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56201207dac53e2f38e848e31b4b91616a6bb6e0c7205b77718994a7f49e70fc" dependencies = [ - "base64", + "base64 0.22.1", "byteorder", "bytes", "fallible-iterator 0.2.0", @@ -5250,7 +5350,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", @@ -5598,6 +5698,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_cbor_2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aec2709de9078e077090abd848e967abab63c9fb3fdb5d4799ad359d8d482c" +dependencies = [ + "half", + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -5890,7 +6000,7 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "chrono", "crc", @@ -5967,7 +6077,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bitflags 2.13.0", "byteorder", "bytes", @@ -6011,7 +6121,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bitflags 2.13.0", "byteorder", "chrono", @@ -6508,7 +6618,7 @@ dependencies = [ "async-stream", "async-trait", "axum", - "base64", + "base64 0.22.1", "bytes", "h2", "http", @@ -6810,6 +6920,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -7045,6 +7156,107 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webauthn-attestation-ca" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6475c0bbd1a3f04afaa3e98880408c5be61680c5e6bd3c6f8c250990d5d3e18e" +dependencies = [ + "base64urlsafedata", + "openssl", + "openssl-sys", + "serde", + "tracing", + "uuid", +] + +[[package]] +name = "webauthn-authenticator-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "779e9c80ff248c7e12ea967f909249101f6e86f70fccd742d4b66c490c1710b4" +dependencies = [ + "async-stream", + "async-trait", + "base64 0.21.7", + "base64urlsafedata", + "bitflags 1.3.2", + "futures", + "hex", + "nom 7.1.3", + "num-derive", + "num-traits", + "openssl", + "openssl-sys", + "serde", + "serde_bytes", + "serde_cbor_2", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tracing", + "unicode-normalization", + "url", + "uuid", + "webauthn-rs-core", + "webauthn-rs-proto", +] + +[[package]] +name = "webauthn-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c548915e0e92ee946bbf2aecf01ea21bef53d974b0793cc6732ba81a03fc422" +dependencies = [ + "base64urlsafedata", + "serde", + "tracing", + "url", + "uuid", + "webauthn-rs-core", +] + +[[package]] +name = "webauthn-rs-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "296d2d501feb715d80b8e186fb88bab1073bca17f460303a1013d17b673bea6a" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "der-parser 9.0.0", + "hex", + "nom 7.1.3", + "openssl", + "openssl-sys", + "rand 0.9.4", + "rand_chacha 0.9.0", + "serde", + "serde_cbor_2", + "serde_json", + "thiserror 1.0.69", + "tracing", + "url", + "uuid", + "webauthn-attestation-ca", + "webauthn-rs-proto", + "x509-parser 0.16.0", +] + +[[package]] +name = "webauthn-rs-proto" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c37393beac9c1ed1ca6dbb30b1e01783fb316ab3a45d90ecd48c99052dd7ef1e" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "serde", + "serde_json", + "url", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -7523,18 +7735,35 @@ dependencies = [ "spki", ] +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs 0.6.2", + "data-encoding", + "der-parser 9.0.0", + "lazy_static", + "nom 7.1.3", + "oid-registry 0.7.1", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "x509-parser" version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4569f339c0c402346d4a75a9e39cf8dad310e287eef1ff56d4c68e5067f53460" dependencies = [ - "asn1-rs", + "asn1-rs 0.7.2", "data-encoding", - "der-parser", + "der-parser 10.0.0", "lazy_static", "nom 7.1.3", - "oid-registry", + "oid-registry 0.8.1", "rusticata-macros", "thiserror 2.0.18", "time", diff --git a/Cargo.toml b/Cargo.toml index f3315734..f109f1b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,7 +90,7 @@ members = [ ] [workspace.package] -version = "26.6.28" +version = "26.6.29" 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.28" } -firefly-kernel = { path = "crates/kernel", version = "26.6.28" } -firefly-utils = { path = "crates/utils", version = "26.6.28" } -firefly-validators = { path = "crates/validators", version = "26.6.28" } -firefly-web = { path = "crates/web", version = "26.6.28" } -firefly-config = { path = "crates/config", version = "26.6.28" } -firefly-i18n = { path = "crates/i18n", version = "26.6.28" } -firefly-cache = { path = "crates/cache", version = "26.6.28" } -firefly-observability = { path = "crates/observability", version = "26.6.28" } -firefly-data = { path = "crates/data", version = "26.6.28" } -firefly-cqrs = { path = "crates/cqrs", version = "26.6.28" } -firefly-eda = { path = "crates/eda", version = "26.6.28" } -firefly-eventsourcing = { path = "crates/eventsourcing", version = "26.6.28" } -firefly-orchestration = { path = "crates/orchestration", version = "26.6.28" } -firefly-rule-engine = { path = "crates/rule-engine", version = "26.6.28" } -firefly-plugins = { path = "crates/plugins", version = "26.6.28" } -firefly-lifecycle = { path = "crates/lifecycle", version = "26.6.28" } -firefly-actuator = { path = "crates/actuator", version = "26.6.28" } -firefly-scheduling = { path = "crates/scheduling", version = "26.6.28" } -firefly-resilience = { path = "crates/resilience", version = "26.6.28" } -firefly-security = { path = "crates/security", version = "26.6.28" } -firefly-migrations = { path = "crates/migrations", version = "26.6.28" } -firefly-openapi = { path = "crates/openapi", version = "26.6.28" } -firefly-sse = { path = "crates/sse", version = "26.6.28" } -firefly-transactional = { path = "crates/transactional", version = "26.6.28" } -firefly-testkit = { path = "crates/testkit", version = "26.6.28" } -firefly-client = { path = "crates/client", version = "26.6.28" } -firefly-config-server = { path = "crates/config-server", version = "26.6.28" } -firefly-idp = { path = "crates/idp", version = "26.6.28" } -firefly-idp-internal-db = { path = "crates/idp-internal-db", version = "26.6.28" } -firefly-idp-keycloak = { path = "crates/idp-keycloak", version = "26.6.28" } -firefly-idp-azure-ad = { path = "crates/idp-azure-ad", version = "26.6.28" } -firefly-idp-aws-cognito = { path = "crates/idp-aws-cognito", version = "26.6.28" } -firefly-ecm = { path = "crates/ecm", version = "26.6.28" } -firefly-ecm-storage-aws = { path = "crates/ecm-storage-aws", version = "26.6.28" } -firefly-ecm-storage-azure = { path = "crates/ecm-storage-azure", version = "26.6.28" } -firefly-ecm-esignature-docusign = { path = "crates/ecm-esignature-docusign", version = "26.6.28" } -firefly-ecm-esignature-adobe-sign = { path = "crates/ecm-esignature-adobe-sign", version = "26.6.28" } -firefly-ecm-esignature-logalty = { path = "crates/ecm-esignature-logalty", version = "26.6.28" } -firefly-notifications = { path = "crates/notifications", version = "26.6.28" } -firefly-notifications-sendgrid = { path = "crates/notifications-sendgrid", version = "26.6.28" } -firefly-notifications-resend = { path = "crates/notifications-resend", version = "26.6.28" } -firefly-notifications-twilio = { path = "crates/notifications-twilio", version = "26.6.28" } -firefly-notifications-firebase = { path = "crates/notifications-firebase", version = "26.6.28" } -firefly-callbacks = { path = "crates/callbacks", version = "26.6.28" } -firefly-webhooks = { path = "crates/webhooks", version = "26.6.28" } -firefly-starter-core = { path = "crates/starter-core", version = "26.6.28" } -firefly-starter-application = { path = "crates/starter-application", version = "26.6.28" } -firefly-starter-domain = { path = "crates/starter-domain", version = "26.6.28" } -firefly-starter-data = { path = "crates/starter-data", version = "26.6.28" } -firefly-backoffice = { path = "crates/backoffice", version = "26.6.28" } -firefly-admin = { path = "crates/admin", version = "26.6.28" } -firefly-aop = { path = "crates/aop", version = "26.6.28" } -firefly-cli = { path = "crates/cli", version = "26.6.28" } -firefly-container = { path = "crates/container", version = "26.6.28" } -firefly-session = { path = "crates/session", version = "26.6.28" } -firefly-shell = { path = "crates/shell", version = "26.6.28" } -firefly-websocket = { path = "crates/websocket", version = "26.6.28" } -firefly-notifications-smtp = { path = "crates/notifications-smtp", version = "26.6.28" } -firefly-cache-redis = { path = "crates/cache-redis", version = "26.6.28" } -firefly-eda-kafka = { path = "crates/eda-kafka", version = "26.6.28" } -firefly-eda-rabbitmq = { path = "crates/eda-rabbitmq", version = "26.6.28" } -firefly-eda-postgres = { path = "crates/eda-postgres", version = "26.6.28" } -firefly-eda-redis = { path = "crates/eda-redis", version = "26.6.28" } -firefly-cache-postgres = { path = "crates/cache-postgres", version = "26.6.28" } -firefly-starter-web = { path = "crates/starter-web", version = "26.6.28" } -firefly = { path = "crates/firefly", version = "26.6.28" } -firefly-macros = { path = "crates/macros", version = "26.6.28" } -firefly-data-sqlx = { path = "crates/data-sqlx", version = "26.6.28" } -firefly-data-mongodb = { path = "crates/data-mongodb", version = "26.6.28" } -firefly-session-redis = { path = "crates/session-redis", version = "26.6.28" } -firefly-session-postgres = { path = "crates/session-postgres", version = "26.6.28" } -firefly-session-mongodb = { path = "crates/session-mongodb", version = "26.6.28" } -firefly-starter-experience = { path = "crates/starter-experience", version = "26.6.28" } +firefly-reactive = { path = "crates/reactive", version = "26.6.29" } +firefly-kernel = { path = "crates/kernel", version = "26.6.29" } +firefly-utils = { path = "crates/utils", version = "26.6.29" } +firefly-validators = { path = "crates/validators", version = "26.6.29" } +firefly-web = { path = "crates/web", version = "26.6.29" } +firefly-config = { path = "crates/config", version = "26.6.29" } +firefly-i18n = { path = "crates/i18n", version = "26.6.29" } +firefly-cache = { path = "crates/cache", version = "26.6.29" } +firefly-observability = { path = "crates/observability", version = "26.6.29" } +firefly-data = { path = "crates/data", version = "26.6.29" } +firefly-cqrs = { path = "crates/cqrs", version = "26.6.29" } +firefly-eda = { path = "crates/eda", version = "26.6.29" } +firefly-eventsourcing = { path = "crates/eventsourcing", version = "26.6.29" } +firefly-orchestration = { path = "crates/orchestration", version = "26.6.29" } +firefly-rule-engine = { path = "crates/rule-engine", version = "26.6.29" } +firefly-plugins = { path = "crates/plugins", version = "26.6.29" } +firefly-lifecycle = { path = "crates/lifecycle", version = "26.6.29" } +firefly-actuator = { path = "crates/actuator", version = "26.6.29" } +firefly-scheduling = { path = "crates/scheduling", version = "26.6.29" } +firefly-resilience = { path = "crates/resilience", version = "26.6.29" } +firefly-security = { path = "crates/security", version = "26.6.29" } +firefly-migrations = { path = "crates/migrations", version = "26.6.29" } +firefly-openapi = { path = "crates/openapi", version = "26.6.29" } +firefly-sse = { path = "crates/sse", version = "26.6.29" } +firefly-transactional = { path = "crates/transactional", version = "26.6.29" } +firefly-testkit = { path = "crates/testkit", version = "26.6.29" } +firefly-client = { path = "crates/client", version = "26.6.29" } +firefly-config-server = { path = "crates/config-server", version = "26.6.29" } +firefly-idp = { path = "crates/idp", version = "26.6.29" } +firefly-idp-internal-db = { path = "crates/idp-internal-db", version = "26.6.29" } +firefly-idp-keycloak = { path = "crates/idp-keycloak", version = "26.6.29" } +firefly-idp-azure-ad = { path = "crates/idp-azure-ad", version = "26.6.29" } +firefly-idp-aws-cognito = { path = "crates/idp-aws-cognito", version = "26.6.29" } +firefly-ecm = { path = "crates/ecm", version = "26.6.29" } +firefly-ecm-storage-aws = { path = "crates/ecm-storage-aws", version = "26.6.29" } +firefly-ecm-storage-azure = { path = "crates/ecm-storage-azure", version = "26.6.29" } +firefly-ecm-esignature-docusign = { path = "crates/ecm-esignature-docusign", version = "26.6.29" } +firefly-ecm-esignature-adobe-sign = { path = "crates/ecm-esignature-adobe-sign", version = "26.6.29" } +firefly-ecm-esignature-logalty = { path = "crates/ecm-esignature-logalty", version = "26.6.29" } +firefly-notifications = { path = "crates/notifications", version = "26.6.29" } +firefly-notifications-sendgrid = { path = "crates/notifications-sendgrid", version = "26.6.29" } +firefly-notifications-resend = { path = "crates/notifications-resend", version = "26.6.29" } +firefly-notifications-twilio = { path = "crates/notifications-twilio", version = "26.6.29" } +firefly-notifications-firebase = { path = "crates/notifications-firebase", version = "26.6.29" } +firefly-callbacks = { path = "crates/callbacks", version = "26.6.29" } +firefly-webhooks = { path = "crates/webhooks", version = "26.6.29" } +firefly-starter-core = { path = "crates/starter-core", version = "26.6.29" } +firefly-starter-application = { path = "crates/starter-application", version = "26.6.29" } +firefly-starter-domain = { path = "crates/starter-domain", version = "26.6.29" } +firefly-starter-data = { path = "crates/starter-data", version = "26.6.29" } +firefly-backoffice = { path = "crates/backoffice", version = "26.6.29" } +firefly-admin = { path = "crates/admin", version = "26.6.29" } +firefly-aop = { path = "crates/aop", version = "26.6.29" } +firefly-cli = { path = "crates/cli", version = "26.6.29" } +firefly-container = { path = "crates/container", version = "26.6.29" } +firefly-session = { path = "crates/session", version = "26.6.29" } +firefly-shell = { path = "crates/shell", version = "26.6.29" } +firefly-websocket = { path = "crates/websocket", version = "26.6.29" } +firefly-notifications-smtp = { path = "crates/notifications-smtp", version = "26.6.29" } +firefly-cache-redis = { path = "crates/cache-redis", version = "26.6.29" } +firefly-eda-kafka = { path = "crates/eda-kafka", version = "26.6.29" } +firefly-eda-rabbitmq = { path = "crates/eda-rabbitmq", version = "26.6.29" } +firefly-eda-postgres = { path = "crates/eda-postgres", version = "26.6.29" } +firefly-eda-redis = { path = "crates/eda-redis", version = "26.6.29" } +firefly-cache-postgres = { path = "crates/cache-postgres", version = "26.6.29" } +firefly-starter-web = { path = "crates/starter-web", version = "26.6.29" } +firefly = { path = "crates/firefly", version = "26.6.29" } +firefly-macros = { path = "crates/macros", version = "26.6.29" } +firefly-data-sqlx = { path = "crates/data-sqlx", version = "26.6.29" } +firefly-data-mongodb = { path = "crates/data-mongodb", version = "26.6.29" } +firefly-session-redis = { path = "crates/session-redis", version = "26.6.29" } +firefly-session-postgres = { path = "crates/session-postgres", version = "26.6.29" } +firefly-session-mongodb = { path = "crates/session-mongodb", version = "26.6.29" } +firefly-starter-experience = { path = "crates/starter-experience", version = "26.6.29" } # ---- 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 0ad020c0..cdede0f2 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`, JWKS `JwksVerifier`, `oauth2` (client registrations + PKCE/OIDC login + authorization server), `RoleHierarchy`, `CsrfLayer`, `PasswordEncoder` (bcrypt) | +| [`firefly-security`](crates/security/README.md) | `Authentication` extension, `BearerLayer`, RBAC `FilterChain` (`ROLE_`-aware, path-segment-safe), JWKS `JwksVerifier` (RSA/EC/EdDSA, `nbf` + clock-skew), `oauth2` (PKCE/OIDC login + authorization server), **one-time-token + WebAuthn/passkey** passwordless login, `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/idp-internal-db/src/lib.rs b/crates/idp-internal-db/src/lib.rs index 0302a6eb..e147f519 100644 --- a/crates/idp-internal-db/src/lib.rs +++ b/crates/idp-internal-db/src/lib.rs @@ -355,14 +355,25 @@ impl firefly_idp::Adapter for Adapter { /// Both an unknown username and a bcrypt mismatch surface as /// [`Error::InvalidCredentials`] — callers can't probe for usernames. async fn login(&self, username: &str, password: &str) -> Result { - let (user, hash) = { + let lookup = { let inner = self.inner.read().expect("user store lock poisoned"); - let id = inner + inner .by_username .get(username) - .ok_or(Error::InvalidCredentials)?; - let record = inner.users.get(id).ok_or(Error::InvalidCredentials)?; - (record.user.clone(), record.hash.clone()) + .and_then(|id| inner.users.get(id)) + .map(|record| (record.user.clone(), record.hash.clone())) + }; + let (user, hash) = match lookup { + Some(found) => found, + None => { + // Unknown username: spend comparable bcrypt time (one hash at + // the configured cost) so the not-found path is indistinguishable + // by latency from a wrong password — Spring's + // DaoAuthenticationProvider userNotFoundEncodedPassword guard, + // closing the user-enumeration timing oracle. + let _ = bcrypt::hash(password, self.cost); + return Err(Error::InvalidCredentials); + } }; if !user.enabled { return Err(Error::InvalidCredentials); @@ -944,6 +955,37 @@ mod tests { ); } + // H13: an unknown username must run comparable bcrypt work to a wrong + // password so the two are indistinguishable by latency (no user + // enumeration oracle). Without the mitigation the not-found path returns + // ~instantly while the wrong-password path pays the bcrypt cost. + #[tokio::test] + async fn login_unknown_user_runs_comparable_bcrypt_work() { + use std::time::{Duration, Instant}; + let a = test_adapter(); + a.create_user(alice(), "pw").await.unwrap(); + + const ITERS: u32 = 5; + let mut unknown = Duration::ZERO; + let mut wrong = Duration::ZERO; + for _ in 0..ITERS { + let t = Instant::now(); + let _ = a.login("nobody", "pw").await; + unknown += t.elapsed(); + + let t = Instant::now(); + let _ = a.login("alice", "wrong-password").await; + wrong += t.elapsed(); + } + // The unknown-user path must not be dramatically faster than the + // wrong-password path (generous 5x bound to stay non-flaky). + assert!( + unknown * 5 >= wrong, + "unknown-user login suspiciously fast: unknown={unknown:?} wrong={wrong:?} \ + (timing oracle — bcrypt not run on the not-found path)" + ); + } + // ----------------------------------------------------------------- // Rust-specific: token verification failure modes (Go error strings // byte-for-byte). diff --git a/crates/security/Cargo.toml b/crates/security/Cargo.toml index 423d6889..a5e10717 100644 --- a/crates/security/Cargo.toml +++ b/crates/security/Cargo.toml @@ -8,6 +8,12 @@ authors.workspace = true rust-version.workspace = true description = "Firefly Framework for Rust — Authentication context, bearer middleware, RBAC filter chain" +[features] +# WebAuthn / passkey login (Spring Security 6.4 `webAuthn()` parity). Opt-in +# because it pulls in the `webauthn-rs` ceremony engine (and a system OpenSSL). +# The default build does not compile the `webauthn` module at all. +webauthn = ["dep:webauthn-rs", "dep:uuid"] + [dependencies] axum = { workspace = true } tower = { workspace = true } @@ -16,6 +22,7 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } async-trait = { workspace = true } +tracing = { workspace = true } tokio = { workspace = true } reqwest = { workspace = true } jsonwebtoken = { workspace = true } @@ -31,7 +38,18 @@ bcrypt = { workspace = true } argon2 = { version = "0.5", features = ["std"] } firefly-session = { workspace = true } +# --- `webauthn` feature ---------------------------------------------------- +# The WebAuthn/passkey ceremony engine (relying-party side). Optional so the +# default build is untouched; enabled by the `webauthn` feature above. +webauthn-rs = { version = "0.5.5", optional = true } +# Stable per-user handles (`user.id` in the WebAuthn `PublicKeyCredentialUser`) +# are random UUIDs; `serde` lets the in-memory handle map round-trip if needed. +uuid = { version = "1", features = ["v4", "serde"], optional = true } + [dev-dependencies] tokio = { workspace = true } http-body-util = { workspace = true } tower = { workspace = true } +# Software authenticator that drives the registration/authentication ceremonies +# end to end in tests (mirrors a real browser/platform authenticator). +webauthn-authenticator-rs = { version = "0.5.5", features = ["softpasskey"] } diff --git a/crates/security/README.md b/crates/security/README.md index 8cb1f0af..19be0f9a 100644 --- a/crates/security/README.md +++ b/crates/security/README.md @@ -14,8 +14,16 @@ authorization tier**: resulting `Authentication` on the request extensions. * `FilterChain` — path-prefix-keyed RBAC matcher composable with the bearer layer; `FilterChain::layer()` yields the tower layer. + Path-segment-aware and `ROLE_`-prefix aware (Spring's `hasRole`). * `Authentication` — principal + authorities tuple persisted on the request for downstream handlers and CQRS handlers alike. +* **Passwordless login** — `ott` (one-time-token / magic-link, Spring 6.4 + `oneTimeTokenLogin()`) and the feature-gated `webauthn` module + (passkeys, Spring 6.4 `webAuthn()`; `--features webauthn`). +* Spring Security 6-faithful hardening: JWKS RSA/EC/EdDSA with `nbf` + + configurable clock-skew, secure-only HSTS, scheme-aware CSRF `Secure` + cookie, RFC 6750 `WWW-Authenticate` challenge, `Argon2PasswordEncoder`. + See the book's **Spring Security Parity** appendix for the full matrix. ## Mental model diff --git a/crates/security/src/authentication.rs b/crates/security/src/authentication.rs index 6ae72c66..a4bfe334 100644 --- a/crates/security/src/authentication.rs +++ b/crates/security/src/authentication.rs @@ -47,8 +47,17 @@ pub struct Authentication { impl Authentication { /// Reports whether the authentication carries `role`. + /// + /// Spring's `hasRole('ADMIN')` checks for the authority `ROLE_ADMIN`. To + /// stay drop-in for ported Spring/JWT principals, a role matches either as a + /// bare name (`ADMIN`, Firefly's existing convention) or with the + /// [`ROLE_PREFIX`] (`ROLE_ADMIN`); a `ROLE_`-prefixed *authority* counts too + /// (that is how Spring stores roles). A *bare* authority is not a role — use + /// [`has_authority`](Authentication::has_authority) for that. pub fn has_role(&self, role: &str) -> bool { - self.roles.iter().any(|r| r == role) + let prefixed = format!("{ROLE_PREFIX}{role}"); + self.roles.iter().any(|r| r == role || r == &prefixed) + || self.authorities.iter().any(|a| a == &prefixed) } /// Returns true if any role matches. @@ -85,6 +94,11 @@ impl Authentication { /// access. pub const ANONYMOUS_ID: &str = "anonymous"; +/// The default `GrantedAuthority` role prefix — Spring's `ROLE_`. +/// [`Authentication::has_role`] treats `hasRole('X')` as matching the authority +/// `ROLE_X` (and, for backward compatibility, a bare `X`). +pub const ROLE_PREFIX: &str = "ROLE_"; + /// `SecurityError` is the typed error family of the security tier. /// /// The `Display` strings match the Go port's sentinel errors exactly, @@ -208,6 +222,32 @@ mod tests { assert!(!a.has_any_role(&[])); } + // H2: Spring's hasRole('ADMIN') checks the authority ROLE_ADMIN. A ported + // Spring/JWT principal carrying ROLE_-prefixed authorities must satisfy + // has_role without the caller hand-stripping prefixes — while bare roles + // (the existing convention, e.g. the sample's CUSTOMER) keep working. + #[test] + fn has_role_accepts_spring_role_prefix() { + let mut a = auth(&[]); + a.roles = vec!["ROLE_ADMIN".into()]; + assert!(a.has_role("ADMIN")); // hasRole('ADMIN') matches ROLE_ADMIN + assert!(a.has_role("ROLE_ADMIN")); // the literal still matches + + // A ROLE_-prefixed *authority* also satisfies hasRole. + let mut b = auth(&[]); + b.authorities = vec!["ROLE_OPERATOR".into()]; + assert!(b.has_role("OPERATOR")); + + // Bare roles still work (backward-compatible). + let c = auth(&["CUSTOMER"]); + assert!(c.has_role("CUSTOMER")); + + // A bare authority is NOT a role (the role/authority distinction holds). + let mut d = auth(&[]); + d.authorities = vec!["ADMIN".into()]; + assert!(!d.has_role("ADMIN")); + } + #[test] fn anonymous_has_anonymous_principal_and_nothing_else() { let a = Authentication::anonymous(); diff --git a/crates/security/src/bearer.rs b/crates/security/src/bearer.rs index 047703e6..6d3d958c 100644 --- a/crates/security/src/bearer.rs +++ b/crates/security/src/bearer.rs @@ -87,11 +87,19 @@ impl BearerConfig { } /// Renders a rejection through the custom handler or the canonical - /// 401 problem envelope. + /// 401 problem envelope, with an RFC 6750 `WWW-Authenticate: Bearer` + /// challenge: a bare `Bearer` when no token was presented, or + /// `error="invalid_token"` when a token was present but unusable. fn reject(&self, req: &Request, err: &SecurityError) -> Response { match &self.unauthorized { Some(f) => f(req, err), - None => problem::unauthorized(&err.to_string()), + None => { + let error_code = match err { + SecurityError::Unauthenticated => None, + _ => Some("invalid_token"), + }; + problem::unauthorized_bearer(&err.to_string(), error_code) + } } } } @@ -207,3 +215,71 @@ where }) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::VerifierFn; + use http::header::WWW_AUTHENTICATE; + use tower::ServiceExt; + + fn bearer_layer() -> BearerLayer { + let verifier = VerifierFn(|t: String| async move { + if t == "good" { + Ok(Authentication { + principal: "u1".into(), + ..Default::default() + }) + } else { + Err(SecurityError::verification("bad token")) + } + }); + BearerLayer::new(BearerConfig::new(verifier)) + } + + async fn run(req: Request) -> Response { + let inner = tower::service_fn(|_r: Request| async { + Ok::(Response::new(axum::body::Body::empty())) + }); + bearer_layer().layer(inner).oneshot(req).await.unwrap() + } + + // H8: an invalid token yields RFC 6750 `WWW-Authenticate: Bearer + // error="invalid_token"` so OAuth2 clients can react correctly. + #[tokio::test] + async fn invalid_token_emits_bearer_invalid_token_challenge() { + let req = Request::builder() + .uri("/x") + .header("Authorization", "Bearer nope") + .body(axum::body::Body::empty()) + .unwrap(); + let resp = run(req).await; + assert_eq!(resp.status(), http::StatusCode::UNAUTHORIZED); + let wa = resp + .headers() + .get(WWW_AUTHENTICATE) + .expect("WWW-Authenticate present") + .to_str() + .unwrap(); + assert!(wa.starts_with("Bearer"), "got {wa}"); + assert!(wa.contains("error=\"invalid_token\""), "got {wa}"); + } + + // H8: a missing token yields a plain `WWW-Authenticate: Bearer` challenge. + #[tokio::test] + async fn missing_token_emits_plain_bearer_challenge() { + let req = Request::builder() + .uri("/x") + .body(axum::body::Body::empty()) + .unwrap(); + let resp = run(req).await; + assert_eq!(resp.status(), http::StatusCode::UNAUTHORIZED); + let wa = resp + .headers() + .get(WWW_AUTHENTICATE) + .expect("WWW-Authenticate present") + .to_str() + .unwrap(); + assert_eq!(wa, "Bearer"); + } +} diff --git a/crates/security/src/config.rs b/crates/security/src/config.rs index 1ee81a7a..d30a6851 100644 --- a/crates/security/src/config.rs +++ b/crates/security/src/config.rs @@ -81,6 +81,10 @@ pub struct JwtProperties { pub algorithm: String, /// Token TTL in seconds for the HMAC service; `0` leaves the default. pub expiration_seconds: u64, + /// Clock-skew tolerance in seconds applied to `exp`/`nbf` validation; `0` + /// (the default) leaves the verifier's built-in 60s (Spring's + /// `JwtTimestampValidator` default). + pub clock_skew_seconds: u64, } /// Bearer-middleware settings. @@ -109,6 +113,9 @@ pub fn verifier_from_config( if !props.audience.trim().is_empty() { verifier = verifier.audience(props.audience.clone()); } + if props.clock_skew_seconds > 0 { + verifier = verifier.clock_skew_seconds(props.clock_skew_seconds); + } return Ok(Some(Arc::new(verifier))); } if !props.secret.trim().is_empty() { @@ -119,6 +126,9 @@ pub fn verifier_from_config( if props.expiration_seconds > 0 { service = service.expiration_seconds(props.expiration_seconds); } + if props.clock_skew_seconds > 0 { + service = service.clock_skew_seconds(props.clock_skew_seconds); + } return Ok(Some(Arc::new(service))); } Ok(None) @@ -199,6 +209,40 @@ mod tests { assert_eq!(auth.principal, "alice"); } + // Batch 6: a configured clock-skew is applied to the built verifier. A + // token that expired 90s ago is rejected under the default 60s leeway but + // accepted once the config widens the skew to 120s. + #[tokio::test] + async fn configured_clock_skew_is_applied() { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let signer = JwtService::new(b"shared"); + let token = signer + .encode(serde_json::json!({ "sub": "u1", "exp": now - 90 })) + .expect("encode"); + + // Default skew (60s) rejects a 90s-expired token. + let default_v = verifier_from_config(&JwtProperties { + secret: "shared".into(), + ..Default::default() + }) + .unwrap() + .unwrap(); + assert!(default_v.verify(&token).await.is_err()); + + // Configured 120s skew accepts it. + let wide_v = verifier_from_config(&JwtProperties { + secret: "shared".into(), + clock_skew_seconds: 120, + ..Default::default() + }) + .unwrap() + .unwrap(); + assert!(wide_v.verify(&token).await.is_ok()); + } + #[test] fn jwk_set_uri_takes_precedence_over_secret() { // Both set → JWKS wins; the secret is ignored. diff --git a/crates/security/src/csrf.rs b/crates/security/src/csrf.rs index 97e0270b..f805ff88 100644 --- a/crates/security/src/csrf.rs +++ b/crates/security/src/csrf.rs @@ -122,11 +122,59 @@ fn csrf_forbidden(message: &str) -> Response { .expect("static csrf response must build") } +/// Policy for the `Secure` attribute on the CSRF cookie. Spring's +/// `CookieCsrfTokenRepository` only marks the cookie `Secure` on a secure +/// request; a `Secure` cookie over plain HTTP is silently dropped, breaking the +/// double-submit pair. [`Auto`](CookieSecure::Auto) reproduces that behaviour. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum CookieSecure { + /// Mark `Secure` only when the request arrived over HTTPS (default). + #[default] + Auto, + /// Always mark the cookie `Secure`. + Always, + /// Never mark the cookie `Secure` (plain-HTTP dev only). + Never, +} + +impl CookieSecure { + fn applies(self, req: &Request) -> bool { + match self { + CookieSecure::Auto => request_is_secure(req), + CookieSecure::Always => true, + CookieSecure::Never => false, + } + } +} + +/// Whether the request arrived over HTTPS — directly or via a TLS-terminating +/// proxy that set `X-Forwarded-Proto: https`. +fn request_is_secure(req: &Request) -> bool { + if let Some(proto) = req + .headers() + .get("x-forwarded-proto") + .and_then(|v| v.to_str().ok()) + { + if proto + .split(',') + .next() + .map(|s| s.trim().eq_ignore_ascii_case("https")) + .unwrap_or(false) + { + return true; + } + } + req.uri().scheme_str() == Some("https") +} + /// Sets (or rotates) the CSRF cookie on `resp` — readable by JS -/// (`HttpOnly` off), `SameSite=Lax`, `Secure`, path `/`, mirroring the -/// pyfly filter's cookie attributes. -fn set_csrf_cookie(resp: &mut Response, token: &str) { - let cookie = format!("{CSRF_COOKIE_NAME}={token}; Path=/; SameSite=Lax; Secure"); +/// (`HttpOnly` off), `SameSite=Lax`, path `/`, and `Secure` only when +/// `secure` (so the double-submit pair also works over plain-HTTP dev). +fn set_csrf_cookie(resp: &mut Response, token: &str, secure: bool) { + let mut cookie = format!("{CSRF_COOKIE_NAME}={token}; Path=/; SameSite=Lax"); + if secure { + cookie.push_str("; Secure"); + } if let Ok(value) = HeaderValue::from_str(&cookie) { resp.headers_mut().append(header::SET_COOKIE, value); } @@ -144,12 +192,22 @@ fn set_csrf_cookie(resp: &mut Response, token: &str) { /// .layer(CsrfLayer::new()); /// ``` #[derive(Debug, Clone, Default)] -pub struct CsrfLayer; +pub struct CsrfLayer { + cookie_secure: CookieSecure, +} impl CsrfLayer { - /// Returns the CSRF protection layer. + /// Returns the CSRF protection layer with the request-driven + /// [`CookieSecure::Auto`] cookie policy. pub fn new() -> Self { - Self + Self::default() + } + + /// Sets the `Secure`-attribute policy for the CSRF cookie + /// (default [`CookieSecure::Auto`]). + pub fn cookie_secure(mut self, policy: CookieSecure) -> Self { + self.cookie_secure = policy; + self } } @@ -157,7 +215,10 @@ impl Layer for CsrfLayer { type Service = CsrfService; fn layer(&self, inner: S) -> Self::Service { - CsrfService { inner } + CsrfService { + inner, + cookie_secure: self.cookie_secure, + } } } @@ -165,6 +226,7 @@ impl Layer for CsrfLayer { #[derive(Clone)] pub struct CsrfService { inner: S, + cookie_secure: CookieSecure, } impl Service for CsrfService @@ -183,12 +245,14 @@ where fn call(&mut self, req: Request) -> Self::Future { let clone = self.inner.clone(); let mut inner = std::mem::replace(&mut self.inner, clone); + let cookie_secure = self.cookie_secure; Box::pin(async move { + let secure = cookie_secure.applies(&req); // Safe methods — pass through and set/refresh the cookie. if is_safe_method(req.method()) { let mut resp = inner.call(req).await?; - set_csrf_cookie(&mut resp, &generate_csrf_token()); + set_csrf_cookie(&mut resp, &generate_csrf_token(), secure); return Ok(resp); } @@ -218,7 +282,7 @@ where // Valid — proceed and rotate the token. let mut resp = inner.call(req).await?; - set_csrf_cookie(&mut resp, &generate_csrf_token()); + set_csrf_cookie(&mut resp, &generate_csrf_token(), secure); Ok(resp) }) } @@ -281,4 +345,38 @@ mod tests { assert_eq!(cookie_value(&req, CSRF_COOKIE_NAME).as_deref(), Some("tok")); assert_eq!(cookie_value(&req, "missing"), None); } + + // H4: the Secure attribute follows the request scheme under the default + // Auto policy, and can be forced or disabled. + #[tokio::test] + async fn cookie_secure_follows_scheme_and_policy() { + use tower::ServiceExt; + + async fn cookie(layer: CsrfLayer, proto: Option<&str>) -> String { + let inner = tower::service_fn(|_r: Request| async { + Ok::(Response::new(Body::empty())) + }); + let svc = layer.layer(inner); + let mut b = Request::builder().method(Method::GET).uri("/p"); + if let Some(p) = proto { + b = b.header("x-forwarded-proto", p); + } + let resp = svc.oneshot(b.body(Body::empty()).unwrap()).await.unwrap(); + resp.headers() + .get(header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .to_string() + } + + assert!(!cookie(CsrfLayer::new(), None).await.contains("Secure")); + assert!(cookie(CsrfLayer::new(), Some("https")).await.contains("Secure")); + assert!(cookie(CsrfLayer::new().cookie_secure(CookieSecure::Always), None) + .await + .contains("Secure")); + assert!(!cookie(CsrfLayer::new().cookie_secure(CookieSecure::Never), Some("https")) + .await + .contains("Secure")); + } } diff --git a/crates/security/src/filter_chain.rs b/crates/security/src/filter_chain.rs index d76f3f08..87366a7d 100644 --- a/crates/security/src/filter_chain.rs +++ b/crates/security/src/filter_chain.rs @@ -30,7 +30,7 @@ use axum::response::Response; use globset::{GlobBuilder, GlobMatcher}; use tower::{Layer, Service}; -use crate::authentication::{Authentication, ANONYMOUS_ID}; +use crate::authentication::{Authentication, SecurityError, ANONYMOUS_ID, ROLE_PREFIX}; use crate::problem; use crate::role_hierarchy::RoleHierarchy; @@ -94,19 +94,37 @@ impl CompiledRule { } match &self.glob { Some(glob) => glob.is_match(path), - None => path.starts_with(&self.rule.prefix), + None => prefix_matches(path, &self.rule.prefix), } } } +/// Path-segment-aware prefix match — the Rust analog of Spring's +/// `AntPathRequestMatcher`. A non-empty `prefix` matches `path` only when it +/// ends at a path-segment boundary, so `/api` matches `/api` and `/api/...` +/// but **not** `/api-internal` or `/apixyz` (where a raw `starts_with` leaks). +/// An empty prefix matches every path (Go parity for the `""` prefix). +fn prefix_matches(path: &str, prefix: &str) -> bool { + if prefix.is_empty() { + return true; + } + match path.strip_prefix(prefix) { + Some(rest) => rest.is_empty() || rest.starts_with('/') || prefix.ends_with('/'), + None => false, + } +} + /// Compiles `pattern` as an fnmatch-style glob (a `*` crosses `/` -/// segments — pyfly's `fnmatch` semantics). -fn compile_glob(pattern: &str) -> GlobMatcher { - GlobBuilder::new(pattern) +/// segments — pyfly's `fnmatch` semantics), returning a recoverable +/// [`SecurityError`] on an invalid pattern instead of panicking. +fn compile_glob(pattern: &str) -> Result { + Ok(GlobBuilder::new(pattern) .literal_separator(false) .build() - .unwrap_or_else(|e| panic!("firefly/security: invalid glob pattern {pattern:?}: {e}")) - .compile_matcher() + .map_err(|e| { + SecurityError::verification(format!("invalid glob pattern {pattern:?}: {e}")) + })? + .compile_matcher()) } /// `FilterChain` is an ordered list of [`Rule`]s evaluated in @@ -170,12 +188,13 @@ impl FilterChain { /// Appends a glob-pattern public rule (pyfly: /// `request_matchers(pattern).permit_all()`). /// - /// # Panics + /// # Errors / Panics /// - /// Panics if `pattern` is not a valid glob. + /// An invalid glob is surfaced when the chain is converted to a layer: + /// [`layer`](Self::layer) panics, while [`try_layer`](Self::try_layer) + /// returns a recoverable [`SecurityError`]. pub fn permit_pattern(mut self, pattern: impl Into) -> Self { let pattern = pattern.into(); - compile_glob(&pattern); // eager validation self.rules.push(Rule { pattern: Some(pattern), allow: true, @@ -199,12 +218,13 @@ impl FilterChain { /// `request_matchers(pattern).has_any_role(...)`); pass `&[]` for /// "any authenticated principal". /// - /// # Panics + /// # Errors / Panics /// - /// Panics if `pattern` is not a valid glob. + /// An invalid glob is surfaced when the chain is converted to a layer: + /// [`layer`](Self::layer) panics, while [`try_layer`](Self::try_layer) + /// returns a recoverable [`SecurityError`]. pub fn require_pattern(mut self, pattern: impl Into, roles: &[&str]) -> Self { let pattern = pattern.into(); - compile_glob(&pattern); self.rules.push(Rule { pattern: Some(pattern), roles: roles.iter().map(|r| r.to_string()).collect(), @@ -219,12 +239,13 @@ impl FilterChain { /// [`Authentication::authorities`] or as a (hierarchy-expanded) /// role. /// - /// # Panics + /// # Errors / Panics /// - /// Panics if `pattern` is not a valid glob. + /// An invalid glob is surfaced when the chain is converted to a layer: + /// [`layer`](Self::layer) panics, while [`try_layer`](Self::try_layer) + /// returns a recoverable [`SecurityError`]. pub fn require_authority(mut self, pattern: impl Into, authorities: &[&str]) -> Self { let pattern = pattern.into(); - compile_glob(&pattern); self.rules.push(Rule { pattern: Some(pattern), authorities: authorities.iter().map(|a| a.to_string()).collect(), @@ -236,12 +257,13 @@ impl FilterChain { /// Appends a glob-pattern "any authenticated principal" rule /// (pyfly: `request_matchers(pattern).authenticated()`). /// - /// # Panics + /// # Errors / Panics /// - /// Panics if `pattern` is not a valid glob. + /// An invalid glob is surfaced when the chain is converted to a layer: + /// [`layer`](Self::layer) panics, while [`try_layer`](Self::try_layer) + /// returns a recoverable [`SecurityError`]. pub fn authenticated(mut self, pattern: impl Into) -> Self { let pattern = pattern.into(); - compile_glob(&pattern); self.rules.push(Rule { pattern: Some(pattern), ..Rule::default() @@ -253,12 +275,13 @@ impl FilterChain { /// `request_matchers(pattern).deny_all()`); every matching request /// is rejected with 403, authenticated or not. /// - /// # Panics + /// # Errors / Panics /// - /// Panics if `pattern` is not a valid glob. + /// An invalid glob is surfaced when the chain is converted to a layer: + /// [`layer`](Self::layer) panics, while [`try_layer`](Self::try_layer) + /// returns a recoverable [`SecurityError`]. pub fn deny(mut self, pattern: impl Into) -> Self { let pattern = pattern.into(); - compile_glob(&pattern); self.rules.push(Rule { pattern: Some(pattern), deny: true, @@ -319,19 +342,33 @@ impl FilterChain { /// Converts the chain into a tower layer (Go: `Middleware()`). /// Auth must already have been populated by /// [`BearerLayer`](crate::BearerLayer) for non-`allow` rules. + /// + /// # Panics + /// + /// Panics if any pattern rule has an invalid glob. Use + /// [`try_layer`](Self::try_layer) to surface that as a recoverable error. pub fn layer(self) -> FilterChainLayer { - let compiled = self - .rules - .into_iter() - .map(|rule| { - let glob = rule.pattern.as_deref().map(compile_glob); - CompiledRule { rule, glob } - }) - .collect(); - FilterChainLayer { + self.try_layer() + .expect("firefly/security: invalid glob pattern in FilterChain") + } + + /// Converts the chain into a tower layer, returning a recoverable + /// [`SecurityError`] if any pattern rule has an invalid glob — the + /// fail-at-startup-gracefully analog of Spring rejecting bad matcher + /// config with an exception rather than aborting the process. + pub fn try_layer(self) -> Result { + let mut compiled = Vec::with_capacity(self.rules.len()); + for rule in self.rules { + let glob = match rule.pattern.as_deref() { + Some(p) => Some(compile_glob(p)?), + None => None, + }; + compiled.push(CompiledRule { rule, glob }); + } + Ok(FilterChainLayer { rules: Arc::new(compiled), hierarchy: self.hierarchy, - } + }) } } @@ -374,6 +411,23 @@ enum Verdict { Forbidden(&'static str), } +/// Whether a rule's required role `rule_role` is satisfied — `ROLE_`-prefix +/// aware and consistent with [`Authentication::has_role`]: a bare or +/// `ROLE_`-prefixed role in the (hierarchy-expanded) role set matches, as does a +/// `ROLE_`-prefixed *authority* (how Spring stores roles). So a principal +/// carrying `ROLE_ADMIN` satisfies a `require(..., &["ADMIN"])` rule on the URL +/// chain just as it does `#[pre_authorize(role = "ADMIN")]`. +fn role_matches( + rule_role: &str, + effective_roles: &BTreeSet, + authorities: &[String], +) -> bool { + let prefixed = format!("{ROLE_PREFIX}{rule_role}"); + effective_roles.contains(rule_role) + || effective_roles.contains(&prefixed) + || authorities.iter().any(|a| a == &prefixed) +} + fn decide(rules: &[CompiledRule], hierarchy: Option<&RoleHierarchy>, req: &Request) -> Verdict { let method = req.method().as_str(); let path = req.uri().path(); @@ -400,7 +454,12 @@ fn decide(rules: &[CompiledRule], hierarchy: Option<&RoleHierarchy>, req: &Reque Some(h) => h.expand(auth.roles.iter().cloned()), None => auth.roles.iter().cloned().collect(), }; - if !rule.roles.is_empty() && !rule.roles.iter().any(|r| effective_roles.contains(r)) { + if !rule.roles.is_empty() + && !rule + .roles + .iter() + .any(|r| role_matches(r, &effective_roles, &auth.authorities)) + { return Verdict::Forbidden("required role missing"); } if !rule.authorities.is_empty() @@ -449,3 +508,107 @@ where }) } } + +#[cfg(test)] +mod tests { + use super::*; + use tower::ServiceExt; + + async fn status_for(chain: FilterChain, method: &str, path: &str) -> http::StatusCode { + let inner = tower::service_fn(|_req: Request| async { + Ok::(Response::new(axum::body::Body::empty())) + }); + let svc = chain.layer().layer(inner); + let req = Request::builder() + .method(method) + .uri(path) + .body(axum::body::Body::empty()) + .unwrap(); + svc.oneshot(req).await.unwrap().status() + } + + // H3: a prefix rule must be path-segment aware. `permit("/api")` must match + // `/api` and `/api/...` but NOT `/api-internal` / `/apixyz` (Spring's + // AntPathRequestMatcher), where the old raw `starts_with` leaked. + #[tokio::test] + async fn prefix_rule_is_path_segment_aware() { + let chain = || FilterChain::new().permit("/api").any_request_deny(); + assert_eq!( + status_for(chain(), "GET", "/api").await, + http::StatusCode::OK + ); + assert_eq!( + status_for(chain(), "GET", "/api/accounts").await, + http::StatusCode::OK + ); + assert_eq!( + status_for(chain(), "GET", "/api-internal").await, + http::StatusCode::FORBIDDEN + ); + assert_eq!( + status_for(chain(), "GET", "/apixyz").await, + http::StatusCode::FORBIDDEN + ); + } + + // Review fix (H2 consistency): the URL-authorization role check must be + // ROLE_-prefix aware like `has_role`, so a `ROLE_ADMIN` principal satisfies + // a `require(..., ["ADMIN"])` rule (not only `#[pre_authorize]`). + #[tokio::test] + async fn role_rules_are_role_prefix_aware() { + use crate::Authentication; + async fn status(chain: FilterChain, auth: Authentication, path: &str) -> http::StatusCode { + let inner = tower::service_fn(|_r: Request| async { + Ok::(Response::new(axum::body::Body::empty())) + }); + let svc = chain.layer().layer(inner); + let mut req = Request::builder() + .method("GET") + .uri(path) + .body(axum::body::Body::empty()) + .unwrap(); + req.extensions_mut().insert(auth); + svc.oneshot(req).await.unwrap().status() + } + let role = |p: &str, r: &str| Authentication { + principal: p.into(), + roles: vec![r.into()], + ..Default::default() + }; + let chain = || { + FilterChain::new() + .require_pattern("/admin/**", &["ADMIN"]) + .any_request_permit() + }; + // ROLE_-prefixed principal satisfies a bare "ADMIN" rule (Spring parity). + assert_eq!( + status(chain(), role("u1", "ROLE_ADMIN"), "/admin/x").await, + http::StatusCode::OK + ); + // A bare role still works. + assert_eq!( + status(chain(), role("u2", "ADMIN"), "/admin/x").await, + http::StatusCode::OK + ); + // A non-admin is still forbidden. + assert_eq!( + status(chain(), role("u3", "USER"), "/admin/x").await, + http::StatusCode::FORBIDDEN + ); + } + + // H10: an invalid glob must surface as a recoverable error via try_layer, + // not abort the process. A valid pattern still yields Ok (discriminating). + #[test] + fn try_layer_surfaces_invalid_glob_as_error() { + let ok = FilterChain::new() + .require_pattern("/api/**", &["ADMIN"]) + .try_layer(); + assert!(ok.is_ok()); + + let bad = FilterChain::new() + .require_pattern("/admin/[", &["ADMIN"]) + .try_layer(); + assert!(bad.is_err()); + } +} diff --git a/crates/security/src/guards.rs b/crates/security/src/guards.rs index 9c6baf46..28e30d19 100644 --- a/crates/security/src/guards.rs +++ b/crates/security/src/guards.rs @@ -47,6 +47,10 @@ use crate::authentication::{Authentication, SecurityError, ANONYMOUS_ID}; #[derive(Clone)] pub struct AuthorizationGuard { predicate: Arc bool + Send + Sync>, + /// When true, [`authorize`](AuthorizationGuard::authorize) admits even an + /// anonymous / absent principal — Spring's `permitAll()`. Set only by + /// [`permit_all`]; the `and`/`or`/`not` combinators reset it to `false`. + permit_anonymous: bool, } impl std::fmt::Debug for AuthorizationGuard { @@ -63,6 +67,7 @@ where { AuthorizationGuard { predicate: Arc::new(predicate), + permit_anonymous: false, } } @@ -78,6 +83,10 @@ impl AuthorizationGuard { /// [`SecurityError::Forbidden`] — the same split pyfly's /// method-security decorators produce (401 vs 403). pub fn authorize(&self, auth: Option<&Authentication>) -> Result<(), SecurityError> { + // `permitAll()` admits everyone, including anonymous / no principal. + if self.permit_anonymous { + return Ok(()); + } let Some(auth) = auth else { return Err(SecurityError::Unauthenticated); }; @@ -116,11 +125,17 @@ pub fn authenticated() -> AuthorizationGuard { require(|_| true) } -/// Always passes the predicate (SpEL `permitAll()`); note that -/// [`AuthorizationGuard::authorize`] still rejects anonymous callers — -/// use no guard at all for genuinely public surfaces. +/// Admits every caller, including anonymous / no principal (SpEL `permitAll()`). +/// +/// Unlike [`authenticated`], this short-circuits +/// [`AuthorizationGuard::authorize`] to `Ok(())` regardless of the principal — +/// matching Spring's `permitAll()`. The `and`/`or`/`not` combinators drop this +/// property (they rebuild via [`require`]). pub fn permit_all() -> AuthorizationGuard { - require(|_| true) + AuthorizationGuard { + predicate: Arc::new(|_| true), + permit_anonymous: true, + } } /// Never passes (SpEL `denyAll()`). @@ -207,6 +222,38 @@ mod tests { assert!(authenticated().check(&alice)); } + // H14: Spring's permitAll() admits even anonymous / no principal, unlike + // authenticated(). Previously permit_all().authorize(None) wrongly 401'd. + #[test] + fn permit_all_allows_anonymous_and_missing_principal() { + assert_eq!(permit_all().authorize(None), Ok(())); + assert_eq!( + permit_all().authorize(Some(&Authentication::anonymous())), + Ok(()) + ); + // authenticated() still rejects anonymous (unchanged). + assert_eq!( + authenticated().authorize(None), + Err(SecurityError::Unauthenticated) + ); + assert_eq!( + authenticated().authorize(Some(&Authentication::anonymous())), + Err(SecurityError::Unauthenticated) + ); + } + + // The and/or/not combinators rebuild via require(), so they drop + // permit_all()'s anonymous-admitting property (documented behaviour). + #[test] + fn permit_all_combinators_reset_anonymity() { + assert_eq!( + permit_all().and(authenticated()).authorize(None), + Err(SecurityError::Unauthenticated) + ); + // permit_all() alone still admits anonymous. + assert_eq!(permit_all().authorize(None), Ok(())); + } + #[test] fn typed_predicates_replace_spel() { // pyfly: @pre_authorize("principal.user_id == 'u1'") diff --git a/crates/security/src/jwks.rs b/crates/security/src/jwks.rs index 5eb52e00..d559aa55 100644 --- a/crates/security/src/jwks.rs +++ b/crates/security/src/jwks.rs @@ -15,11 +15,13 @@ //! JWKS-based OAuth2 resource-server verifier (pyfly: //! `pyfly.security.oauth2.resource_server.JWKSTokenValidator`). //! -//! [`JwksVerifier`] validates RS256-signed JWTs against a remote JWKS -//! endpoint: it fetches the provider's public keys once, caches them by -//! `kid`, and verifies signature, `exp` (required), and optional -//! `iss`/`aud` claims. It implements the crate's [`Verifier`] port, so -//! it drops straight into [`BearerLayer`](crate::BearerLayer). +//! [`JwksVerifier`] validates asymmetrically-signed JWTs (RSA `RS*`/`PS*`, +//! EC `ES256`/`ES384`, and `EdDSA`) against a remote JWKS endpoint: it +//! fetches the provider's public keys once, caches them by `kid`, and +//! verifies the signature, `exp` (required), `nbf` (when present), and the +//! optional `iss`/`aud` claims with a configurable clock-skew leeway. It +//! implements the crate's [`Verifier`] port, so it drops straight into +//! [`BearerLayer`](crate::BearerLayer). use std::collections::HashMap; @@ -33,18 +35,24 @@ use crate::authentication::{Authentication, SecurityError, Verifier}; pub use jsonwebtoken::Algorithm; -/// One key of a JWKS document (only the RSA members the verifier -/// needs). +/// One key of a JWKS document (the RSA, EC, and OKP/EdDSA members the +/// verifier needs). #[derive(Debug, Deserialize)] struct Jwk { #[serde(default)] kty: String, #[serde(default)] kid: Option, + // RSA components. #[serde(default)] n: Option, #[serde(default)] e: Option, + // EC (`x`/`y`) and OKP/EdDSA (`x`) components. + #[serde(default)] + x: Option, + #[serde(default)] + y: Option, } /// A JWKS document: `{"keys": [...]}`. @@ -77,10 +85,15 @@ pub struct JwksVerifier { issuer: Option, audience: Option, algorithms: Vec, + leeway_seconds: u64, http: reqwest::Client, keys: RwLock>, } +/// Default clock-skew tolerance (seconds) applied to `exp`/`nbf` validation — +/// matches Spring's `JwtTimestampValidator` default of 60s. +pub const DEFAULT_CLOCK_SKEW_SECONDS: u64 = 60; + impl std::fmt::Debug for JwksVerifier { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("JwksVerifier") @@ -93,14 +106,30 @@ impl std::fmt::Debug for JwksVerifier { } impl JwksVerifier { - /// Builds a verifier for `jwks_uri` with pyfly defaults: no issuer - /// or audience validation, `RS256` only, `exp` required. + /// Builds a verifier for `jwks_uri` with Spring-resource-server defaults: + /// no issuer or audience validation; `exp` required; `nbf` validated when + /// present; a 60s clock-skew leeway; and the standard asymmetric JWS + /// algorithm family allowed (RSA `RS*`/`PS*`, EC `ES256`/`ES384`, and + /// `EdDSA`) — matching `NimbusJwtDecoder` deriving algorithms from the JWK + /// set. The symmetric `HS*` family is never allowed (it would enable an + /// algorithm-confusion attack against the public keys). pub fn new(jwks_uri: impl Into) -> Self { Self { jwks_uri: jwks_uri.into(), issuer: None, audience: None, - algorithms: vec![Algorithm::RS256], + algorithms: vec![ + Algorithm::RS256, + Algorithm::RS384, + Algorithm::RS512, + Algorithm::PS256, + Algorithm::PS384, + Algorithm::PS512, + Algorithm::ES256, + Algorithm::ES384, + Algorithm::EdDSA, + ], + leeway_seconds: DEFAULT_CLOCK_SKEW_SECONDS, http: reqwest::Client::new(), keys: RwLock::new(HashMap::new()), } @@ -118,12 +147,21 @@ impl JwksVerifier { self } - /// Overrides the allowed signing algorithms (default `[RS256]`). + /// Overrides the allowed signing algorithms (default: the asymmetric + /// `RS*`/`PS*`/`ES256`/`ES384`/`EdDSA` family). pub fn algorithms(mut self, algorithms: Vec) -> Self { self.algorithms = algorithms; self } + /// Overrides the clock-skew leeway in seconds applied to `exp`/`nbf` + /// validation (default [`DEFAULT_CLOCK_SKEW_SECONDS`]). Spring's + /// `JwtTimestampValidator` defaults to 60s. + pub fn clock_skew_seconds(mut self, seconds: u64) -> Self { + self.leeway_seconds = seconds; + self + } + /// Validates `token` and returns the decoded payload — the Rust /// analog of pyfly's `JWKSTokenValidator.validate`. Errors carry /// the pyfly message shape `Token validation failed: `. @@ -141,7 +179,10 @@ impl JwksVerifier { let key = self.signing_key(&kid).await?; let mut validation = Validation::new(header.alg); - validation.leeway = 0; + validation.leeway = self.leeway_seconds; + // Reject not-yet-valid tokens (a future `nbf`), which jsonwebtoken + // does not check by default. `exp` is required and validated too. + validation.validate_nbf = true; validation.set_required_spec_claims(&["exp"]); if let Some(iss) = &self.issuer { validation.set_issuer(&[iss]); @@ -194,13 +235,28 @@ impl JwksVerifier { let mut keys = HashMap::new(); for jwk in doc.keys { - let (Some(kid), Some(n), Some(e)) = (jwk.kid, jwk.n, jwk.e) else { + let Some(kid) = jwk.kid else { continue; }; - if jwk.kty != "RSA" { - continue; - } - if let Ok(key) = DecodingKey::from_rsa_components(&n, &e) { + // Build the decoding key per key type. Unknown types or keys + // missing their components are skipped (not an error) so one bad + // entry never poisons the set. + let key = match jwk.kty.as_str() { + "RSA" => match (jwk.n.as_deref(), jwk.e.as_deref()) { + (Some(n), Some(e)) => DecodingKey::from_rsa_components(n, e).ok(), + _ => None, + }, + "EC" => match (jwk.x.as_deref(), jwk.y.as_deref()) { + (Some(x), Some(y)) => DecodingKey::from_ec_components(x, y).ok(), + _ => None, + }, + "OKP" => jwk + .x + .as_deref() + .and_then(|x| DecodingKey::from_ed_components(x).ok()), + _ => None, + }; + if let Some(key) = key { keys.insert(kid, key); } } diff --git a/crates/security/src/jwt.rs b/crates/security/src/jwt.rs index a47c69a6..b1a38b4c 100644 --- a/crates/security/src/jwt.rs +++ b/crates/security/src/jwt.rs @@ -71,6 +71,7 @@ pub const DEFAULT_EXPIRATION_SECONDS: u64 = 3600; pub struct JwtService { algorithm: Algorithm, expiration_seconds: u64, + leeway_seconds: u64, encoding_key: EncodingKey, decoding_key: DecodingKey, } @@ -92,6 +93,7 @@ impl JwtService { Self { algorithm: Algorithm::HS256, expiration_seconds: DEFAULT_EXPIRATION_SECONDS, + leeway_seconds: crate::jwks::DEFAULT_CLOCK_SKEW_SECONDS, encoding_key: EncodingKey::from_secret(bytes), decoding_key: DecodingKey::from_secret(bytes), } @@ -121,6 +123,15 @@ impl JwtService { self } + /// Overrides the clock-skew leeway in seconds applied to `exp`/`nbf` + /// validation in [`Self::decode`] (default + /// [`DEFAULT_CLOCK_SKEW_SECONDS`](crate::DEFAULT_CLOCK_SKEW_SECONDS), + /// matching Spring's 60s `JwtTimestampValidator`). + pub fn clock_skew_seconds(mut self, seconds: u64) -> Self { + self.leeway_seconds = seconds; + self + } + /// The configured signing algorithm. pub fn signing_algorithm(&self) -> Algorithm { self.algorithm @@ -161,9 +172,10 @@ impl JwtService { /// lacks `exp`. pub fn decode(&self, token: &str) -> Result, SecurityError> { let mut validation = Validation::new(self.algorithm); - // No clock leeway — pyfly's PyJWT default rejects an `exp` in the - // past with no grace window; match it (the JWKS verifier does too). - validation.leeway = 0; + // Spring's JwtTimestampValidator allows a small clock-skew window + // (default 60s) on `exp`/`nbf`; the JWKS verifier matches. + validation.leeway = self.leeway_seconds; + validation.validate_nbf = true; validation.set_required_spec_claims(&["exp"]); validation.validate_aud = false; let data = decode::>(token, &self.decoding_key, &validation) @@ -352,6 +364,27 @@ mod tests { assert!(err.to_string().starts_with("Invalid token:"), "got {err}"); } + // H6: an exp just barely in the past (within the default 60s clock-skew + // leeway) is still accepted, matching Spring's JwtTimestampValidator. + #[test] + fn decode_allows_exp_within_clock_skew_leeway() { + let svc = svc(); + let token = svc + .encode(json!({ "sub": "u1", "exp": now_secs() - 30 })) + .unwrap(); + assert!(svc.decode(&token).is_ok()); + } + + // H7: a token whose nbf is in the future (beyond leeway) is rejected. + #[test] + fn decode_rejects_future_nbf() { + let svc = svc(); + let token = svc + .encode(json!({ "sub": "u1", "nbf": now_secs() + 3600, "exp": now_secs() + 7200 })) + .unwrap(); + assert!(svc.decode(&token).is_err()); + } + // Ported from pyfly: test_to_security_context #[test] fn to_authentication_maps_claims() { diff --git a/crates/security/src/lib.rs b/crates/security/src/lib.rs index 84ddafd4..3a26d0c0 100644 --- a/crates/security/src/lib.rs +++ b/crates/security/src/lib.rs @@ -146,14 +146,17 @@ pub mod guards; mod jwks; mod jwt; pub mod oauth2; +mod ott; mod password; mod problem; mod role_hierarchy; mod session_auth; +#[cfg(feature = "webauthn")] +mod webauthn; pub use authentication::{ authentication_from, must_auth_from, with_authentication, Authentication, SecurityError, - Verifier, VerifierFn, ANONYMOUS_ID, + Verifier, VerifierFn, ANONYMOUS_ID, ROLE_PREFIX, }; pub use bearer::{BearerConfig, BearerLayer, BearerService, UnauthorizedHandler}; pub use config::{ @@ -165,19 +168,33 @@ pub use context::{ with_authentication_scope_sync, AccessRule, }; pub use csrf::{ - generate_csrf_token, is_safe_method, validate_csrf_token, CsrfLayer, CsrfService, + generate_csrf_token, is_safe_method, validate_csrf_token, CookieSecure, CsrfLayer, CsrfService, CSRF_COOKIE_NAME, CSRF_HEADER_NAME, SAFE_METHODS, }; pub use filter_chain::{FilterChain, FilterChainLayer, FilterChainService, Rule}; pub use guards::{require, AuthorizationGuard}; -pub use jwks::{claims_to_authentication, Algorithm, JwksVerifier}; +pub use jwks::{ + claims_to_authentication, Algorithm, JwksVerifier, DEFAULT_CLOCK_SKEW_SECONDS, +}; pub use jwt::{authentication_from_claims, JwtService, DEFAULT_EXPIRATION_SECONDS}; +pub use ott::{ + ott_login_routes, InMemoryOneTimeTokenService, LoggingOttHandler, OneTimeToken, + OneTimeTokenGenerationSuccessHandler, OneTimeTokenService, OttLoginState, + DEFAULT_OTT_TTL_SECONDS, +}; pub use password::{Argon2PasswordEncoder, BcryptPasswordEncoder, PasswordEncoder, DEFAULT_ROUNDS}; pub use role_hierarchy::RoleHierarchy; pub use session_auth::{ SessionAuthenticationLayer, SessionAuthenticationService, SessionLoginSession, SessionLoginSessionStore, }; +#[cfg(feature = "webauthn")] +pub use webauthn::{ + webauthn_routes, CeremonyStateStore, InMemoryCeremonyStore, InMemoryPasskeyRepository, + InMemoryUserEntityRepository, PasskeyCredentialRepository, + PublicKeyCredentialUserEntityRepository, WebAuthnError, WebAuthnProperties, + WebAuthnRelyingParty, WebAuthnState, +}; /// Framework version stamp. pub const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/crates/security/src/oauth2/login.rs b/crates/security/src/oauth2/login.rs index 0dcc17f3..0b4c12f6 100644 --- a/crates/security/src/oauth2/login.rs +++ b/crates/security/src/oauth2/login.rs @@ -484,16 +484,25 @@ async fn handle_callback( session.remove_attribute(SESSION_KEY_NONCE).await; let mut authentication: Option = None; if let Some(id_token) = token_response.get("id_token").and_then(Value::as_str) { - if !registration.jwks_uri.is_empty() { - match validate_id_token(®istration, id_token, nonce.as_deref()).await { - Some(auth) => authentication = Some(auth), - None => { - return error_json( - StatusCode::UNAUTHORIZED, - "invalid_id_token", - "ID token validation failed", - ); - } + // An id_token MUST be validated before any claim is trusted (OIDC + // signature/issuer/audience/nonce). If no JWKS is configured we cannot + // validate it, so refuse the login rather than silently falling through + // to the unverified userinfo identity. + if registration.jwks_uri.is_empty() { + return error_json( + StatusCode::UNAUTHORIZED, + "invalid_id_token", + "ID token present but no JWKS endpoint is configured to validate it", + ); + } + match validate_id_token(®istration, id_token, nonce.as_deref()).await { + Some(auth) => authentication = Some(auth), + None => { + return error_json( + StatusCode::UNAUTHORIZED, + "invalid_id_token", + "ID token validation failed", + ); } } } diff --git a/crates/security/src/ott.rs b/crates/security/src/ott.rs new file mode 100644 index 00000000..9c6031b3 --- /dev/null +++ b/crates/security/src/ott.rs @@ -0,0 +1,408 @@ +// 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. + +//! One-time-token (magic-link) login — the Rust analog of Spring Security +//! 6.4's `oneTimeTokenLogin()`. +//! +//! A passwordless flow in two legs: +//! +//! 1. `POST /ott/generate` (`username`) mints a single-use, time-limited token +//! via [`OneTimeTokenService`] and hands it to a +//! [`OneTimeTokenGenerationSuccessHandler`] for out-of-band delivery (email +//! / SMS magic link). The HTTP response never reveals the token or whether +//! the account exists. +//! 2. The user clicks the delivered link — `GET /login/ott?token=…` — which +//! [`consume`](OneTimeTokenService::consume)s the token (single-use, +//! expiry-checked), builds an [`Authentication`], rotates the session id +//! (anti-fixation), stores the security context in the +//! [`firefly_session::Session`], and redirects. +//! +//! [`InMemoryOneTimeTokenService`] ships for single-process apps; a +//! distributed deployment supplies its own [`OneTimeTokenService`] over a +//! shared store. The default [`LoggingOttHandler`] only records that a token +//! was issued (never the token value) — wire a real delivery handler in +//! production. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use async_trait::async_trait; +use axum::extract::{Form, Query, State}; +use axum::response::{IntoResponse, Response}; +use axum::{Extension, Router}; +use http::{header, StatusCode}; +use serde::Deserialize; +use tokio::sync::Mutex; + +use firefly_session::Session; + +use crate::authentication::Authentication; +use crate::csrf::random_urlsafe; +use crate::oauth2::SESSION_KEY_SECURITY_CONTEXT; + +/// Default one-time-token lifetime (5 minutes), matching the short window +/// expected of a magic link. +pub const DEFAULT_OTT_TTL_SECONDS: u64 = 300; + +/// A minted one-time token: its opaque `value`, the `username` it +/// authenticates, and the epoch-seconds `expires_at` after which it is invalid. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OneTimeToken { + /// The opaque, high-entropy token value embedded in the magic link. + pub value: String, + /// The username the token will authenticate when consumed. + pub username: String, + /// Expiry as epoch seconds. + pub expires_at: u64, +} + +/// Mints and redeems one-time login tokens — Spring's `OneTimeTokenService`. +#[async_trait] +pub trait OneTimeTokenService: Send + Sync { + /// Issues a fresh single-use token for `username`. + async fn generate(&self, username: &str) -> OneTimeToken; + /// Redeems `value`, returning the username iff the token is known, + /// unexpired, and not already used. The token is invalidated (single-use) + /// whether or not it had expired. + async fn consume(&self, value: &str) -> Option; +} + +/// In-process [`OneTimeTokenService`] backed by a map — suitable for a single +/// instance. A distributed deployment supplies its own implementation over a +/// shared store (Redis/Postgres) so a link minted on one node redeems on +/// another. +pub struct InMemoryOneTimeTokenService { + ttl_seconds: u64, + tokens: Mutex>, +} + +impl InMemoryOneTimeTokenService { + /// Builds the service with the default [`DEFAULT_OTT_TTL_SECONDS`] lifetime. + #[must_use] + pub fn new() -> Self { + Self { + ttl_seconds: DEFAULT_OTT_TTL_SECONDS, + tokens: Mutex::new(HashMap::new()), + } + } + + /// Overrides the token lifetime in seconds. + #[must_use] + pub fn ttl_seconds(mut self, seconds: u64) -> Self { + self.ttl_seconds = seconds; + self + } +} + +impl Default for InMemoryOneTimeTokenService { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl OneTimeTokenService for InMemoryOneTimeTokenService { + async fn generate(&self, username: &str) -> OneTimeToken { + // 32 bytes of OS entropy → 43-char URL-safe value, as elsewhere in the + // crate (CSRF / OAuth2 state). + let value = random_urlsafe(32); + let expires_at = now_secs().saturating_add(self.ttl_seconds); + self.tokens + .lock() + .await + .insert(value.clone(), (username.to_string(), expires_at)); + OneTimeToken { + value, + username: username.to_string(), + expires_at, + } + } + + async fn consume(&self, value: &str) -> Option { + // Single-use: remove on lookup so a replay (or an expired token) can + // never be redeemed twice. + let (username, expires_at) = self.tokens.lock().await.remove(value)?; + if now_secs() > expires_at { + return None; + } + Some(username) + } +} + +/// Delivers a freshly-minted [`OneTimeToken`] out of band (email / SMS magic +/// link) — Spring's `OneTimeTokenGenerationSuccessHandler`. +#[async_trait] +pub trait OneTimeTokenGenerationSuccessHandler: Send + Sync { + /// Handles a newly-generated token (e.g. emails the magic link). + async fn handle(&self, token: &OneTimeToken); +} + +/// The default handler: records *that* a token was issued, never its value +/// (logging the value would defeat the point). Replace it with a real delivery +/// handler (e.g. over `firefly-notifications`) in production. +#[derive(Debug, Clone, Default)] +pub struct LoggingOttHandler; + +#[async_trait] +impl OneTimeTokenGenerationSuccessHandler for LoggingOttHandler { + async fn handle(&self, token: &OneTimeToken) { + tracing::info!( + username = %token.username, + "one-time login token generated; deliver the magic link out of band \ + (no real delivery handler configured)" + ); + } +} + +/// Shared state for the one-time-token login routes. +pub struct OttLoginState { + /// Mints and redeems tokens. + pub service: Arc, + /// Delivers a generated token's magic link. + pub handler: Arc, + /// Where to redirect after a successful login (default `"/"`). + pub success_redirect: String, +} + +impl OttLoginState { + /// Builds the state from a service and a delivery handler, redirecting to + /// `"/"` on success. + #[must_use] + pub fn new( + service: Arc, + handler: Arc, + ) -> Self { + Self { + service, + handler, + success_redirect: "/".to_string(), + } + } + + /// Overrides the post-login redirect target. + #[must_use] + pub fn success_redirect(mut self, target: impl Into) -> Self { + self.success_redirect = target.into(); + self + } +} + +#[derive(Deserialize)] +struct GenerateBody { + username: String, +} + +#[derive(Deserialize)] +struct ConsumeParams { + token: String, +} + +/// `POST /ott/generate` — mint a token and hand it to the delivery handler. +/// The response is deliberately generic so it neither leaks the token nor +/// reveals whether the account exists (Spring's behaviour). +async fn handle_generate( + State(state): State>, + Form(body): Form, +) -> Response { + let token = state.service.generate(&body.username).await; + state.handler.handle(&token).await; + ( + StatusCode::OK, + "If the account exists, a one-time login link has been sent.", + ) + .into_response() +} + +/// `GET /login/ott?token=…` — redeem a token (single-use, expiry-checked), +/// establish the session security context, rotate the session id, and redirect. +async fn handle_login( + State(state): State>, + Extension(session): Extension, + Query(params): Query, +) -> Response { + let Some(username) = state.service.consume(¶ms.token).await else { + return crate::problem::unauthorized("Invalid or expired one-time token"); + }; + + let auth = Authentication { + principal: username.clone(), + username, + ..Default::default() + }; + + // Anti-fixation: rotate the session id on authentication, then store the + // security context where `SessionAuthenticationLayer` restores it. + session.rotate_id().await; + let serialized = serde_json::to_string(&auth).expect("Authentication serializes to JSON"); + let _ = session + .set_attribute(SESSION_KEY_SECURITY_CONTEXT, serialized) + .await; + + redirect(&state.success_redirect) +} + +/// Builds the one-time-token login routes (`POST /ott/generate`, +/// `GET /login/ott`). Mount behind a [`firefly_session::SessionLayer`] so a +/// [`Session`] is available; pair with a +/// [`SessionAuthenticationLayer`](crate::SessionAuthenticationLayer) to restore +/// the established context on subsequent requests. +pub fn ott_login_routes(state: Arc) -> Router { + Router::new() + .route("/ott/generate", axum::routing::post(handle_generate)) + .route("/login/ott", axum::routing::get(handle_login)) + .with_state(state) +} + +/// 302 redirect to `location`. +fn redirect(location: &str) -> Response { + Response::builder() + .status(StatusCode::FOUND) + .header(header::LOCATION, location) + .body(axum::body::Body::empty()) + .expect("static redirect response must build") +} + +/// The current wall-clock time in epoch seconds. +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use firefly_session::SessionInner; + use tower::ServiceExt; + + fn svc() -> InMemoryOneTimeTokenService { + InMemoryOneTimeTokenService::new() + } + + #[tokio::test] + async fn generate_then_consume_authenticates_user() { + let s = svc(); + let token = s.generate("alice").await; + assert_eq!(token.username, "alice"); + assert_eq!(token.value.len(), 43); + assert_eq!(s.consume(&token.value).await.as_deref(), Some("alice")); + } + + #[tokio::test] + async fn token_is_single_use() { + let s = svc(); + let token = s.generate("bob").await; + assert_eq!(s.consume(&token.value).await.as_deref(), Some("bob")); + // A replay fails. + assert_eq!(s.consume(&token.value).await, None); + } + + #[tokio::test] + async fn unknown_token_is_rejected() { + let s = svc(); + assert_eq!(s.consume("not-a-real-token").await, None); + } + + #[tokio::test] + async fn expired_token_is_rejected() { + let s = svc(); + // Insert a token whose expiry is already in the past — deterministically + // expired (no reliance on wall-clock advancing during the test). + s.tokens + .lock() + .await + .insert("past".into(), ("carol".into(), now_secs().saturating_sub(10))); + assert_eq!(s.consume("past").await, None, "expired token must be rejected"); + // ...and it is removed on the attempt, so a retry also fails. + assert_eq!(s.consume("past").await, None); + } + + fn router(captured: Arc>>) -> (Router, Arc) { + struct Capturing(Arc>>); + #[async_trait] + impl OneTimeTokenGenerationSuccessHandler for Capturing { + async fn handle(&self, token: &OneTimeToken) { + *self.0.lock().await = Some(token.clone()); + } + } + let service: Arc = Arc::new(svc()); + let state = Arc::new(OttLoginState::new( + service.clone(), + Arc::new(Capturing(captured)), + )); + (ott_login_routes(state), service) + } + + #[tokio::test] + async fn generate_endpoint_delivers_token_without_leaking_it() { + let captured = Arc::new(Mutex::new(None)); + let (app, _) = router(captured.clone()); + + let req = http::Request::builder() + .method(http::Method::POST) + .uri("/ott/generate") + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(axum::body::Body::from("username=dave")) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + // The response body must not contain the token value. + let token = captured.lock().await.clone().expect("handler captured a token"); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + assert!(!String::from_utf8_lossy(&body).contains(&token.value)); + assert_eq!(token.username, "dave"); + } + + #[tokio::test] + async fn login_endpoint_consumes_token_and_sets_session_context() { + let captured = Arc::new(Mutex::new(None)); + let (app, service) = router(captured); + let token = service.generate("erin").await; + + let session = Session::new(SessionInner::new("sid")); + let id_before = session.id().await; + let mut req = http::Request::builder() + .uri(format!("/login/ott?token={}", token.value)) + .body(axum::body::Body::empty()) + .unwrap(); + req.extensions_mut().insert(session.clone()); + + let resp = app.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::FOUND); + assert_eq!(resp.headers()[header::LOCATION], "/"); + + // Anti-fixation: the session id must rotate on login. + assert_ne!(session.id().await, id_before, "session id must rotate on login"); + + // The security context is now stored on the session. + let ctx = session + .attribute::(SESSION_KEY_SECURITY_CONTEXT) + .await + .expect("security context stored"); + let auth: Authentication = serde_json::from_str(&ctx).unwrap(); + assert_eq!(auth.principal, "erin"); + + // A replay of the same link fails (single-use), even with a session. + let session2 = Session::new(SessionInner::new("sid2")); + let mut req2 = http::Request::builder() + .uri(format!("/login/ott?token={}", token.value)) + .body(axum::body::Body::empty()) + .unwrap(); + req2.extensions_mut().insert(session2); + let resp2 = app.oneshot(req2).await.unwrap(); + assert_eq!(resp2.status(), StatusCode::UNAUTHORIZED); + } +} diff --git a/crates/security/src/problem.rs b/crates/security/src/problem.rs index c8e209f3..b0a8b9ae 100644 --- a/crates/security/src/problem.rs +++ b/crates/security/src/problem.rs @@ -51,6 +51,36 @@ pub(crate) fn forbidden(detail: &str) -> Response { write(StatusCode::FORBIDDEN, TYPE_FORBIDDEN, "Forbidden", detail) } +/// Builds a 401 problem response carrying an RFC 6750 +/// `WWW-Authenticate: Bearer` challenge — what an OAuth2 resource server must +/// emit so clients can distinguish "no credentials" from "invalid token". +/// +/// `error_code` is the RFC 6750 error (`invalid_token` / `insufficient_scope`), +/// or `None` for the bare `Bearer` challenge used when no token was presented. +pub(crate) fn unauthorized_bearer(detail: &str, error_code: Option<&str>) -> Response { + let mut resp = unauthorized(detail); + let challenge = match error_code { + Some(code) => format!( + "Bearer error=\"{}\", error_description=\"{}\"", + sanitize(code), + sanitize(detail) + ), + None => "Bearer".to_string(), + }; + if let Ok(value) = http::HeaderValue::from_str(&challenge) { + resp.headers_mut().insert(header::WWW_AUTHENTICATE, value); + } + resp +} + +/// Strips characters that would break (or inject into) a `WWW-Authenticate` +/// quoted-string value. +fn sanitize(s: &str) -> String { + s.chars() + .filter(|c| *c != '"' && *c != '\\' && !c.is_control()) + .collect() +} + /// Serializes the envelope. Members are inserted in alphabetical order /// so the byte layout is stable regardless of the `serde_json` map /// backend, matching Go's sorted map marshalling. diff --git a/crates/security/src/session_auth.rs b/crates/security/src/session_auth.rs index d8c60b02..16d0c161 100644 --- a/crates/security/src/session_auth.rs +++ b/crates/security/src/session_auth.rs @@ -185,16 +185,29 @@ where Box::pin(async move { let restored = restore_authentication(session).await; - match restored { + // Make the context ambient two ways: insert it into the request + // extensions (read by `FilterChain` / `guards` / handlers) AND scope + // the task-local `CURRENT_AUTH` around the downstream call (read by + // the `#[pre_authorize]` / `#[post_authorize]` macros, `check_access`, + // and `current_authentication()`) — exactly as `BearerLayer` does. + // Without the task-local scope, method security silently fails for a + // session-authenticated caller. + let scoped = match restored { Some(auth) => { - req.extensions_mut().insert(auth); + req.extensions_mut().insert(auth.clone()); + Some(auth) } None if anonymous_fallback && !already_present => { - req.extensions_mut().insert(Authentication::anonymous()); + let anon = Authentication::anonymous(); + req.extensions_mut().insert(anon.clone()); + Some(anon) } - None => {} + None => None, + }; + match scoped { + Some(auth) => crate::with_authentication_scope(auth, inner.call(req)).await, + None => inner.call(req).await, } - inner.call(req).await }) } } @@ -587,4 +600,84 @@ mod tests { s1.invalidate().await; assert!(!store.exists(&sid).await.unwrap()); } + + // H1: the layer must scope the *task-local* `CURRENT_AUTH` (what + // `#[pre_authorize]` / `check_access` read), not just the request + // extension — otherwise method security silently fails for a + // session-authenticated caller (it only worked behind `BearerLayer`). + #[tokio::test] + async fn service_scopes_task_local_for_session_authenticated_request() { + use std::sync::Mutex; + use tower::ServiceExt; + + let session = Session::new(SessionInner::new("sid")); + let auth = Authentication { + principal: "u1".into(), + username: "alice".into(), + roles: vec!["ADMIN".into()], + ..Default::default() + }; + session + .set_attribute( + SESSION_KEY_SECURITY_CONTEXT, + serde_json::to_string(&auth).unwrap(), + ) + .await + .unwrap(); + + // Inner service records what `current_authentication()` (the task-local) + // reports at call time — the contract method security depends on. + let seen: Arc>> = Arc::new(Mutex::new(None)); + let probe = seen.clone(); + let inner = tower::service_fn(move |_req: Request| { + let probe = probe.clone(); + async move { + *probe.lock().unwrap() = crate::current_authentication().map(|a| a.principal); + Ok::(Response::new(axum::body::Body::empty())) + } + }); + + let mut req = Request::new(axum::body::Body::empty()); + req.extensions_mut().insert(session); + + let _ = SessionAuthenticationLayer::new() + .layer(inner) + .oneshot(req) + .await + .unwrap(); + + assert_eq!(seen.lock().unwrap().as_deref(), Some("u1")); + } + + // H1 (anonymous path): with the default anonymous fallback, the layer + // should scope an anonymous context so downstream method security sees a + // present-but-anonymous principal (Spring's AnonymousAuthenticationFilter). + #[tokio::test] + async fn service_scopes_anonymous_when_no_session_context() { + use std::sync::Mutex; + use tower::ServiceExt; + + let seen: Arc>> = Arc::new(Mutex::new(None)); + let probe = seen.clone(); + let inner = tower::service_fn(move |_req: Request| { + let probe = probe.clone(); + async move { + *probe.lock().unwrap() = crate::current_authentication().map(|a| a.principal); + Ok::(Response::new(axum::body::Body::empty())) + } + }); + + // No Session handle at all -> anonymous fallback applies. + let req = Request::new(axum::body::Body::empty()); + let _ = SessionAuthenticationLayer::new() + .layer(inner) + .oneshot(req) + .await + .unwrap(); + + assert_eq!( + seen.lock().unwrap().as_deref(), + Some(crate::authentication::ANONYMOUS_ID) + ); + } } diff --git a/crates/security/src/webauthn.rs b/crates/security/src/webauthn.rs new file mode 100644 index 00000000..aafe1ef5 --- /dev/null +++ b/crates/security/src/webauthn.rs @@ -0,0 +1,932 @@ +// 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. + +//! WebAuthn / passkey login — the Rust analog of Spring Security 6.4's +//! `webAuthn()` (FIDO2 / passkeys). +//! +//! A passwordless, phishing-resistant flow built on the [`webauthn-rs`] relying +//! party. As in Spring, a ceremony has two HTTP legs — an *options* leg that +//! issues a cryptographic challenge, and a *verify* leg that consumes the +//! authenticator's signed response — for each of registration and +//! authentication: +//! +//! 1. **Register** a passkey for an already-identified user: +//! - `POST /webauthn/register/options` (`{ "username": … }`) → +//! [`start_passkey_registration`](WebAuthnRelyingParty::start_passkey_registration) +//! returns a `CreationChallengeResponse` (the +//! `PublicKeyCredentialCreationOptions` the browser hands to +//! `navigator.credentials.create`). The in-progress +//! [`PasskeyRegistration`] state is stashed server-side, keyed by username. +//! - `POST /webauthn/register` (the browser's `RegisterPublicKeyCredential` +//! JSON) → +//! [`finish_passkey_registration`](WebAuthnRelyingParty::finish_passkey_registration) +//! validates the attestation and persists the resulting [`Passkey`] via the +//! [`PasskeyCredentialRepository`]. +//! 2. **Authenticate** with a registered passkey: +//! - `POST /webauthn/authenticate/options` (`{ "username": … }`) → +//! [`start_passkey_authentication`](WebAuthnRelyingParty::start_passkey_authentication) +//! returns a `RequestChallengeResponse`; the [`PasskeyAuthentication`] state +//! is stashed server-side. +//! - `POST /login/webauthn` (the browser's `PublicKeyCredential` JSON) → +//! [`finish_passkey_authentication`](WebAuthnRelyingParty::finish_passkey_authentication) +//! verifies the assertion. On success it bumps the stored credential's +//! signature counter, builds an [`Authentication`], rotates the +//! [`firefly_session::Session`] id (anti-fixation), and stores the security +//! context under [`SESSION_KEY_SECURITY_CONTEXT`](crate::oauth2::SESSION_KEY_SECURITY_CONTEXT) +//! — exactly where [`SessionAuthenticationLayer`](crate::SessionAuthenticationLayer) +//! restores it on later requests. +//! +//! ## State that must live server-side +//! +//! The `webauthn-rs` `PasskeyRegistration` / `PasskeyAuthentication` values +//! returned by the `start_*` calls bind a one-time challenge to its ceremony and +//! **must** be retained, server-side, between the options leg and the verify leg +//! — losing them, or letting the client round-trip them, opens replay attacks. +//! They are deliberately *not* serialisable here (we do not enable +//! `webauthn-rs`'s `danger-allow-state-serialisation`); instead the in-memory +//! [`InMemoryCeremonyStore`] keeps them in a `Mutex>`. A +//! distributed deployment supplies its own [`CeremonyStateStore`] over a shared, +//! short-TTL store. +//! +//! ## Storage +//! +//! Two repositories model what Spring splits across `UserCredentialRepository` +//! and `PublicKeyCredentialUserEntityRepository`: +//! +//! - [`PasskeyCredentialRepository`] — store / list a username's [`Passkey`]s and +//! apply the counter update returned by a successful assertion. +//! - [`PublicKeyCredentialUserEntityRepository`] — maps a username to a stable +//! per-user handle ([`Uuid`]); this handle is the WebAuthn `user.id` and must +//! not change across a user's credentials. +//! +//! [`InMemoryPasskeyRepository`] and [`InMemoryUserEntityRepository`] ship for +//! single-process apps; production wires database-backed implementations. +//! +//! [`webauthn-rs`]: https://docs.rs/webauthn-rs + +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; +use axum::extract::State; +use axum::response::{IntoResponse, Response}; +use axum::routing::post; +use axum::{Extension, Json, Router}; +use http::StatusCode; +use serde::{Deserialize, Serialize}; +use tokio::sync::Mutex; + +use webauthn_rs::prelude::{ + AuthenticationResult, CreationChallengeResponse, Passkey, PasskeyAuthentication, + PasskeyRegistration, PublicKeyCredential, RegisterPublicKeyCredential, RequestChallengeResponse, + Url, Uuid, Webauthn, WebauthnBuilder, +}; + +use firefly_session::Session; + +use crate::authentication::Authentication; +use crate::oauth2::SESSION_KEY_SECURITY_CONTEXT; + +/// Configuration for the WebAuthn relying party — the Rust analog of Spring's +/// `WebAuthnRelyingPartyRegistration` properties. +/// +/// `serde(default)` lets it bind from configuration with sane fallbacks +/// (`localhost` on `https://localhost:8080`), matching the developer-friendly +/// defaults of the Spring/Go ports. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct WebAuthnProperties { + /// The relying-party id — the registrable domain the credentials are scoped + /// to (e.g. `example.com`). Credentials are bound to this and cannot be used + /// against another origin. + pub rp_id: String, + /// The human-readable relying-party name shown by the authenticator UI. + pub rp_name: String, + /// The full origin URL(s) the browser ceremony is served from (scheme + + /// host + optional port, e.g. `https://example.com`). The first is the + /// primary; any extras are additional allowed origins. + pub allowed_origins: Vec, +} + +impl Default for WebAuthnProperties { + fn default() -> Self { + Self { + rp_id: "localhost".to_string(), + rp_name: "Firefly".to_string(), + allowed_origins: vec!["https://localhost:8080".to_string()], + } + } +} + +/// The WebAuthn relying party — a thin, ergonomic wrapper over +/// [`webauthn_rs::Webauthn`] that exposes just the four passkey ceremony calls +/// the login flow needs. +pub struct WebAuthnRelyingParty { + webauthn: Webauthn, + primary_origin: Url, +} + +impl WebAuthnRelyingParty { + /// Builds a relying party for `rp_id`/`rp_name` served from `origin` + /// (`https://host[:port]`). + /// + /// # Errors + /// + /// Returns [`WebAuthnError::InvalidConfiguration`] if `origin` is not a + /// valid URL, or the underlying [`WebauthnBuilder`] rejects the + /// configuration (e.g. the origin's host does not match `rp_id`). + pub fn new(rp_id: &str, rp_name: &str, origin: &str) -> Result { + let rp_origin = Url::parse(origin) + .map_err(|e| WebAuthnError::InvalidConfiguration(e.to_string()))?; + let webauthn = WebauthnBuilder::new(rp_id, &rp_origin) + .map_err(|e| WebAuthnError::InvalidConfiguration(e.to_string()))? + .rp_name(rp_name) + .build() + .map_err(|e| WebAuthnError::InvalidConfiguration(e.to_string()))?; + Ok(Self { + webauthn, + primary_origin: rp_origin, + }) + } + + /// Builds a relying party from [`WebAuthnProperties`]. + /// + /// # Errors + /// + /// As [`WebAuthnRelyingParty::new`]; additionally errors if + /// `allowed_origins` is empty. + pub fn from_properties(props: &WebAuthnProperties) -> Result { + let primary = props.allowed_origins.first().ok_or_else(|| { + WebAuthnError::InvalidConfiguration("at least one allowed origin is required".into()) + })?; + let rp_origin = + Url::parse(primary).map_err(|e| WebAuthnError::InvalidConfiguration(e.to_string()))?; + let mut builder = WebauthnBuilder::new(&props.rp_id, &rp_origin) + .map_err(|e| WebAuthnError::InvalidConfiguration(e.to_string()))? + .rp_name(&props.rp_name); + // Register any additional origins (e.g. www vs apex, native app schemes). + for extra in props.allowed_origins.iter().skip(1) { + let url = Url::parse(extra) + .map_err(|e| WebAuthnError::InvalidConfiguration(e.to_string()))?; + builder = builder.append_allowed_origin(&url); + } + let webauthn = builder + .build() + .map_err(|e| WebAuthnError::InvalidConfiguration(e.to_string()))?; + Ok(Self { + webauthn, + primary_origin: rp_origin, + }) + } + + /// The primary origin URL ceremonies are served from — handy for driving the + /// browser-side `navigator.credentials` call (and for tests). + #[must_use] + pub fn origin(&self) -> &Url { + &self.primary_origin + } + + /// Begins registering a passkey for `user_handle`/`username`, excluding any + /// credentials the user already has so a device isn't enrolled twice. + /// + /// Returns the challenge to send the browser and the in-progress state to + /// stash server-side until [`finish_passkey_registration`](Self::finish_passkey_registration). + /// + /// # Errors + /// + /// Propagates a [`WebAuthnError::Ceremony`] if the engine rejects the + /// request. + pub fn start_passkey_registration( + &self, + user_handle: Uuid, + username: &str, + existing: &[Passkey], + ) -> Result<(CreationChallengeResponse, PasskeyRegistration), WebAuthnError> { + let exclude = if existing.is_empty() { + None + } else { + Some(existing.iter().map(|p| p.cred_id().clone()).collect()) + }; + self.webauthn + .start_passkey_registration(user_handle, username, username, exclude) + .map_err(WebAuthnError::from) + } + + /// Completes registration, validating the authenticator's attestation + /// against the stashed `state`, yielding the [`Passkey`] to persist. + /// + /// # Errors + /// + /// [`WebAuthnError::Ceremony`] if attestation/challenge validation fails. + pub fn finish_passkey_registration( + &self, + credential: &RegisterPublicKeyCredential, + state: &PasskeyRegistration, + ) -> Result { + self.webauthn + .finish_passkey_registration(credential, state) + .map_err(WebAuthnError::from) + } + + /// Begins authenticating against the user's registered `passkeys`. + /// + /// Returns the challenge to send the browser and the in-progress state to + /// stash until [`finish_passkey_authentication`](Self::finish_passkey_authentication). + /// + /// # Errors + /// + /// [`WebAuthnError::Ceremony`] if the engine rejects the request (e.g. the + /// user has no credentials). + pub fn start_passkey_authentication( + &self, + passkeys: &[Passkey], + ) -> Result<(RequestChallengeResponse, PasskeyAuthentication), WebAuthnError> { + self.webauthn + .start_passkey_authentication(passkeys) + .map_err(WebAuthnError::from) + } + + /// Completes authentication, verifying the assertion against the stashed + /// `state`. The returned [`AuthenticationResult`] carries the signature + /// counter that callers must fold back into the stored credential (see + /// [`PasskeyCredentialRepository::update_credential`]). + /// + /// # Errors + /// + /// [`WebAuthnError::Ceremony`] if signature/challenge validation fails. + pub fn finish_passkey_authentication( + &self, + credential: &PublicKeyCredential, + state: &PasskeyAuthentication, + ) -> Result { + self.webauthn + .finish_passkey_authentication(credential, state) + .map_err(WebAuthnError::from) + } +} + +/// Errors raised by the WebAuthn relying party and HTTP routes. +#[derive(Debug, thiserror::Error)] +pub enum WebAuthnError { + /// The relying-party configuration (rp_id / origin) is invalid. + #[error("invalid WebAuthn configuration: {0}")] + InvalidConfiguration(String), + /// A registration or authentication ceremony failed (challenge mismatch, + /// attestation/signature invalid, counter regression, …). + #[error("WebAuthn ceremony failed: {0}")] + Ceremony(String), + /// No in-progress ceremony state was found for the username — the options + /// leg was skipped, expired, or already consumed. + #[error("no in-progress WebAuthn ceremony for this user")] + NoCeremonyInProgress, + /// The user has no registered passkeys to authenticate with. + #[error("no registered passkeys for this user")] + NoCredentials, +} + +impl From for WebAuthnError { + fn from(e: webauthn_rs::prelude::WebauthnError) -> Self { + WebAuthnError::Ceremony(e.to_string()) + } +} + +/// Persists a user's registered passkeys — the Rust analog of Spring's +/// `UserCredentialRepository` (the credential half). +#[async_trait] +pub trait PasskeyCredentialRepository: Send + Sync { + /// Stores a newly-registered [`Passkey`] for `username`. + async fn save(&self, username: &str, passkey: Passkey); + /// Lists every [`Passkey`] registered for `username` (empty when none). + async fn find_by_username(&self, username: &str) -> Vec; + /// Applies the signature-counter update from a successful assertion to the + /// matching stored credential. A no-op if no credential matches. + async fn update_credential(&self, username: &str, result: &AuthenticationResult); +} + +/// Maps a username to its stable per-user handle (`Uuid`) — the Rust analog of +/// Spring's `PublicKeyCredentialUserEntityRepository`. The handle is the +/// WebAuthn `user.id`; it must be stable across all of a user's credentials and +/// must never be reused for a different user. +#[async_trait] +pub trait PublicKeyCredentialUserEntityRepository: Send + Sync { + /// Returns the existing handle for `username`, minting and storing a fresh + /// random one on first use. + async fn user_handle(&self, username: &str) -> Uuid; +} + +/// Holds the one-time `PasskeyRegistration` / `PasskeyAuthentication` ceremony +/// state between an options leg and its verify leg. Keyed by username; the verify +/// leg removes the entry (single-use). +#[async_trait] +pub trait CeremonyStateStore: Send + Sync { + /// Stashes the in-progress registration state for `username`. + async fn put_registration(&self, username: &str, state: PasskeyRegistration); + /// Removes and returns the registration state for `username`, if any. + async fn take_registration(&self, username: &str) -> Option; + /// Stashes the in-progress authentication state for `username`. + async fn put_authentication(&self, username: &str, state: PasskeyAuthentication); + /// Removes and returns the authentication state for `username`, if any. + async fn take_authentication(&self, username: &str) -> Option; +} + +/// In-memory [`PasskeyCredentialRepository`] for single-process apps. +#[derive(Default)] +pub struct InMemoryPasskeyRepository { + by_user: Mutex>>, +} + +impl InMemoryPasskeyRepository { + /// Builds an empty repository. + #[must_use] + pub fn new() -> Self { + Self::default() + } +} + +#[async_trait] +impl PasskeyCredentialRepository for InMemoryPasskeyRepository { + async fn save(&self, username: &str, passkey: Passkey) { + self.by_user + .lock() + .await + .entry(username.to_string()) + .or_default() + .push(passkey); + } + + async fn find_by_username(&self, username: &str) -> Vec { + self.by_user + .lock() + .await + .get(username) + .cloned() + .unwrap_or_default() + } + + async fn update_credential(&self, username: &str, result: &AuthenticationResult) { + if let Some(creds) = self.by_user.lock().await.get_mut(username) { + for passkey in creds.iter_mut() { + // `update_credential` returns `Some(_)` for the matching cred id + // and folds in the new counter / backup state. + if passkey.update_credential(result).is_some() { + break; + } + } + } + } +} + +/// In-memory [`PublicKeyCredentialUserEntityRepository`] for single-process apps. +/// Handles are random v4 UUIDs minted on first sight of a username and held +/// stable thereafter. +#[derive(Default)] +pub struct InMemoryUserEntityRepository { + handles: Mutex>, +} + +impl InMemoryUserEntityRepository { + /// Builds an empty repository. + #[must_use] + pub fn new() -> Self { + Self::default() + } +} + +#[async_trait] +impl PublicKeyCredentialUserEntityRepository for InMemoryUserEntityRepository { + async fn user_handle(&self, username: &str) -> Uuid { + *self + .handles + .lock() + .await + .entry(username.to_string()) + .or_insert_with(Uuid::new_v4) + } +} + +/// In-memory [`CeremonyStateStore`] keyed by username. The non-serialisable +/// `webauthn-rs` ceremony states never leave the process, as required. +#[derive(Default)] +pub struct InMemoryCeremonyStore { + registrations: Mutex>, + authentications: Mutex>, +} + +impl InMemoryCeremonyStore { + /// Builds an empty store. + #[must_use] + pub fn new() -> Self { + Self::default() + } +} + +#[async_trait] +impl CeremonyStateStore for InMemoryCeremonyStore { + async fn put_registration(&self, username: &str, state: PasskeyRegistration) { + self.registrations + .lock() + .await + .insert(username.to_string(), state); + } + + async fn take_registration(&self, username: &str) -> Option { + self.registrations.lock().await.remove(username) + } + + async fn put_authentication(&self, username: &str, state: PasskeyAuthentication) { + self.authentications + .lock() + .await + .insert(username.to_string(), state); + } + + async fn take_authentication(&self, username: &str) -> Option { + self.authentications.lock().await.remove(username) + } +} + +/// Shared state for the WebAuthn login routes — the relying party plus the three +/// stores and the post-login redirect target. +pub struct WebAuthnState { + /// The ceremony engine. + pub relying_party: Arc, + /// Stores registered passkeys. + pub credentials: Arc, + /// Maps usernames to stable user handles. + pub user_entities: Arc, + /// Holds in-progress ceremony state between legs. + pub ceremonies: Arc, + /// Where to redirect after a successful `POST /login/webauthn` (default + /// `"/"`). + pub success_redirect: String, +} + +impl WebAuthnState { + /// Builds state from a relying party, wiring in-memory stores and a `"/"` + /// success redirect. + #[must_use] + pub fn new(relying_party: Arc) -> Self { + Self { + relying_party, + credentials: Arc::new(InMemoryPasskeyRepository::new()), + user_entities: Arc::new(InMemoryUserEntityRepository::new()), + ceremonies: Arc::new(InMemoryCeremonyStore::new()), + success_redirect: "/".to_string(), + } + } + + /// Overrides the credential repository. + #[must_use] + pub fn credentials(mut self, repo: Arc) -> Self { + self.credentials = repo; + self + } + + /// Overrides the user-entity repository. + #[must_use] + pub fn user_entities( + mut self, + repo: Arc, + ) -> Self { + self.user_entities = repo; + self + } + + /// Overrides the ceremony-state store. + #[must_use] + pub fn ceremonies(mut self, store: Arc) -> Self { + self.ceremonies = store; + self + } + + /// Overrides the post-login redirect target. + #[must_use] + pub fn success_redirect(mut self, target: impl Into) -> Self { + self.success_redirect = target.into(); + self + } +} + +#[derive(Deserialize)] +struct UsernameBody { + username: String, +} + +/// `POST /webauthn/register/options` — begin registering a passkey for the named +/// (already-identified) user. Returns the `CreationChallengeResponse` for +/// `navigator.credentials.create` and stashes the registration state. +async fn handle_register_options( + State(state): State>, + Json(body): Json, +) -> Response { + let handle = state.user_entities.user_handle(&body.username).await; + let existing = state.credentials.find_by_username(&body.username).await; + match state + .relying_party + .start_passkey_registration(handle, &body.username, &existing) + { + Ok((challenge, registration)) => { + state + .ceremonies + .put_registration(&body.username, registration) + .await; + (StatusCode::OK, Json(challenge)).into_response() + } + Err(e) => { + tracing::warn!(error = %e, "passkey registration options failed"); + crate::problem::unauthorized("Could not begin passkey registration") + } + } +} + +#[derive(Deserialize)] +struct RegisterBody { + username: String, + credential: RegisterPublicKeyCredential, +} + +/// `POST /webauthn/register` — finish registration: validate the attestation +/// against the stashed state and persist the resulting passkey. +async fn handle_register( + State(state): State>, + Json(body): Json, +) -> Response { + let Some(registration) = state.ceremonies.take_registration(&body.username).await else { + return crate::problem::unauthorized("No passkey registration in progress"); + }; + match state + .relying_party + .finish_passkey_registration(&body.credential, ®istration) + { + Ok(passkey) => { + state.credentials.save(&body.username, passkey).await; + StatusCode::OK.into_response() + } + Err(e) => { + tracing::warn!(error = %e, "passkey registration failed"); + crate::problem::unauthorized("Passkey registration failed") + } + } +} + +/// `POST /webauthn/authenticate/options` — begin authenticating the named user. +/// Returns the `RequestChallengeResponse` for `navigator.credentials.get` and +/// stashes the authentication state. +async fn handle_authenticate_options( + State(state): State>, + Json(body): Json, +) -> Response { + let passkeys = state.credentials.find_by_username(&body.username).await; + if passkeys.is_empty() { + return crate::problem::unauthorized("No registered passkeys for this user"); + } + match state.relying_party.start_passkey_authentication(&passkeys) { + Ok((challenge, authentication)) => { + state + .ceremonies + .put_authentication(&body.username, authentication) + .await; + (StatusCode::OK, Json(challenge)).into_response() + } + Err(e) => { + tracing::warn!(error = %e, "passkey authentication options failed"); + crate::problem::unauthorized("Could not begin passkey authentication") + } + } +} + +#[derive(Deserialize)] +struct AuthenticateBody { + username: String, + credential: PublicKeyCredential, +} + +/// `POST /login/webauthn` — finish authentication: verify the assertion, fold +/// the new signature counter into the stored credential, establish the session +/// security context (rotating the session id against fixation), and return the +/// success redirect. Mirrors Spring 6.4's `/login/webauthn` endpoint. +async fn handle_login( + State(state): State>, + Extension(session): Extension, + Json(body): Json, +) -> Response { + let Some(authentication) = state.ceremonies.take_authentication(&body.username).await else { + return crate::problem::unauthorized("No passkey authentication in progress"); + }; + + let result = match state + .relying_party + .finish_passkey_authentication(&body.credential, &authentication) + { + Ok(result) => result, + Err(e) => { + tracing::warn!(error = %e, "passkey authentication failed"); + return crate::problem::unauthorized("Passkey authentication failed"); + } + }; + + // Fold the new signature counter / backup state back into the stored + // credential (cloning detection + replay resistance on the next ceremony). + state + .credentials + .update_credential(&body.username, &result) + .await; + + let auth = Authentication { + principal: body.username.clone(), + username: body.username.clone(), + ..Default::default() + }; + + // Anti-fixation: rotate the session id on authentication, then store the + // security context where `SessionAuthenticationLayer` restores it. + session.rotate_id().await; + let serialized = serde_json::to_string(&auth).expect("Authentication serializes to JSON"); + let _ = session + .set_attribute(SESSION_KEY_SECURITY_CONTEXT, serialized) + .await; + + redirect(&state.success_redirect) +} + +/// Builds the WebAuthn login routes: +/// `POST /webauthn/register/options`, `POST /webauthn/register`, +/// `POST /webauthn/authenticate/options`, and `POST /login/webauthn`. +/// +/// Mount behind a [`firefly_session::SessionLayer`] so a [`Session`] is +/// available on the request; pair with a +/// [`SessionAuthenticationLayer`](crate::SessionAuthenticationLayer) to restore +/// the established context on subsequent requests. +pub fn webauthn_routes(state: Arc) -> Router { + Router::new() + .route("/webauthn/register/options", post(handle_register_options)) + .route("/webauthn/register", post(handle_register)) + .route( + "/webauthn/authenticate/options", + post(handle_authenticate_options), + ) + .route("/login/webauthn", post(handle_login)) + .with_state(state) +} + +/// 302 redirect to `location`. +fn redirect(location: &str) -> Response { + Response::builder() + .status(StatusCode::FOUND) + .header(http::header::LOCATION, location) + .body(axum::body::Body::empty()) + .expect("static redirect response must build") +} + +#[cfg(test)] +mod tests { + use super::*; + use firefly_session::SessionInner; + use tower::ServiceExt; + use webauthn_authenticator_rs::softpasskey::SoftPasskey; + use webauthn_authenticator_rs::WebauthnAuthenticator; + + const ORIGIN: &str = "https://localhost:8080"; + const RP_ID: &str = "localhost"; + + fn rp() -> WebAuthnRelyingParty { + WebAuthnRelyingParty::new(RP_ID, "Firefly Test", ORIGIN).expect("relying party builds") + } + + // --- repository unit tests -------------------------------------------- + + #[tokio::test] + async fn user_handle_is_stable_per_username_and_distinct_across_users() { + let repo = InMemoryUserEntityRepository::new(); + let alice1 = repo.user_handle("alice").await; + let alice2 = repo.user_handle("alice").await; + let bob = repo.user_handle("bob").await; + assert_eq!(alice1, alice2, "same username yields a stable handle"); + assert_ne!(alice1, bob, "different usernames get distinct handles"); + } + + #[tokio::test] + async fn ceremony_store_is_single_use() { + // We need a real PasskeyRegistration to store; mint one via the RP. + let rp = rp(); + let handle = Uuid::new_v4(); + let (_ccr, reg) = rp + .start_passkey_registration(handle, "alice", &[]) + .expect("start registration"); + let store = InMemoryCeremonyStore::new(); + store.put_registration("alice", reg).await; + assert!( + store.take_registration("alice").await.is_some(), + "first take succeeds" + ); + assert!( + store.take_registration("alice").await.is_none(), + "second take is empty (single-use)" + ); + } + + /// Drives the full ceremony at the relying-party level with the software + /// authenticator: register a passkey, then authenticate with it, and confirm + /// the credential round-trips. This proves the `webauthn-rs` integration end + /// to end without the HTTP layer. + #[test] + fn full_ceremony_round_trips_at_relying_party_level() { + let rp = rp(); + let origin = Url::parse(ORIGIN).unwrap(); + // `falsify_uv: true` so the soft authenticator satisfies our + // `UserVerificationPolicy::Required` ceremonies. + let mut authenticator = WebauthnAuthenticator::new(SoftPasskey::new(true)); + + // Registration. + let handle = Uuid::new_v4(); + let (ccr, reg_state) = rp + .start_passkey_registration(handle, "alice", &[]) + .expect("start registration"); + let reg_credential = authenticator + .do_registration(origin.clone(), ccr) + .expect("authenticator registers"); + let passkey = rp + .finish_passkey_registration(®_credential, ®_state) + .expect("finish registration"); + + // Authentication against the freshly-registered passkey. + let (rcr, auth_state) = rp + .start_passkey_authentication(std::slice::from_ref(&passkey)) + .expect("start authentication"); + let auth_credential = authenticator + .do_authentication(origin, rcr) + .expect("authenticator authenticates"); + let result = rp + .finish_passkey_authentication(&auth_credential, &auth_state) + .expect("finish authentication"); + + // The assertion verified against the credential we registered. + assert_eq!( + result.cred_id(), + passkey.cred_id(), + "assertion is for the registered credential" + ); + } + + // --- end-to-end ceremony through the HTTP router ----------------------- + + /// Drives the full flow through the axum `Router`: register-options → + /// authenticator → register-verify, then authenticate-options → + /// authenticator → `/login/webauthn`, and asserts the session security + /// context ends up set to the right principal. + #[tokio::test] + async fn e2e_register_then_login_through_router_sets_session_context() { + let state = Arc::new(WebAuthnState::new(Arc::new(rp()))); + let app = webauthn_routes(state); + let origin = Url::parse(ORIGIN).unwrap(); + let mut authenticator = WebauthnAuthenticator::new(SoftPasskey::new(true)); + + // 1. POST /webauthn/register/options -> CreationChallengeResponse. + let ccr: CreationChallengeResponse = post_json( + &app, + "/webauthn/register/options", + &serde_json::json!({ "username": "alice" }), + None, + ) + .await; + + // 2. Software authenticator produces the attestation. + let reg_credential = authenticator + .do_registration(origin.clone(), ccr) + .expect("authenticator registers"); + + // 3. POST /webauthn/register -> 200 OK, passkey stored. + let resp = post_raw( + &app, + "/webauthn/register", + &serde_json::json!({ "username": "alice", "credential": reg_credential }), + None, + ) + .await; + assert_eq!(resp.status(), StatusCode::OK, "registration verifies"); + + // 4. POST /webauthn/authenticate/options -> RequestChallengeResponse. + let rcr: RequestChallengeResponse = post_json( + &app, + "/webauthn/authenticate/options", + &serde_json::json!({ "username": "alice" }), + None, + ) + .await; + + // 5. Software authenticator produces the assertion. + let auth_credential = authenticator + .do_authentication(origin, rcr) + .expect("authenticator authenticates"); + + // 6. POST /login/webauthn with a session -> 302 + session context set. + let session = Session::new(SessionInner::new("sid")); + let resp = post_raw( + &app, + "/login/webauthn", + &serde_json::json!({ "username": "alice", "credential": auth_credential }), + Some(session.clone()), + ) + .await; + assert_eq!(resp.status(), StatusCode::FOUND, "login redirects on success"); + assert_eq!(resp.headers()[http::header::LOCATION], "/"); + + let ctx = session + .attribute::(SESSION_KEY_SECURITY_CONTEXT) + .await + .expect("security context stored on session"); + let auth: Authentication = serde_json::from_str(&ctx).unwrap(); + assert_eq!(auth.principal, "alice"); + assert_eq!(auth.username, "alice"); + } + + #[tokio::test] + async fn login_without_started_ceremony_is_unauthorized() { + let state = Arc::new(WebAuthnState::new(Arc::new(rp()))); + let app = webauthn_routes(state); + let session = Session::new(SessionInner::new("sid")); + // A bare (but well-formed-enough to deserialize) credential never reaches + // verification because no ceremony was started for this user. + let bogus = minimal_public_key_credential(); + let resp = post_raw( + &app, + "/login/webauthn", + &serde_json::json!({ "username": "nobody", "credential": bogus }), + Some(session.clone()), + ) + .await; + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + assert!( + session + .attribute::(SESSION_KEY_SECURITY_CONTEXT) + .await + .is_none(), + "no context is established on failure" + ); + } + + // --- helpers ----------------------------------------------------------- + + /// POSTs `body` as JSON, optionally with a `Session` extension, returning the + /// raw response. + async fn post_raw( + app: &Router, + uri: &str, + body: &serde_json::Value, + session: Option, + ) -> Response { + let mut req = http::Request::builder() + .method(http::Method::POST) + .uri(uri) + .header(http::header::CONTENT_TYPE, "application/json") + .body(axum::body::Body::from(serde_json::to_vec(body).unwrap())) + .unwrap(); + if let Some(session) = session { + req.extensions_mut().insert(session); + } + app.clone().oneshot(req).await.unwrap() + } + + /// POSTs `body` as JSON and deserializes a `200 OK` JSON response into `T`. + async fn post_json( + app: &Router, + uri: &str, + body: &serde_json::Value, + session: Option, + ) -> T { + let resp = post_raw(app, uri, body, session).await; + assert_eq!(resp.status(), StatusCode::OK, "{uri} returned non-200"); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + serde_json::from_slice(&bytes).expect("response deserializes") + } + + /// A `PublicKeyCredential` shaped enough to deserialize but never valid — + /// used to prove the "no ceremony in progress" guard fires before any + /// cryptographic check. + fn minimal_public_key_credential() -> serde_json::Value { + serde_json::json!({ + "id": "AAAA", + "rawId": "AAAA", + "response": { + "authenticatorData": "AAAA", + "clientDataJSON": "AAAA", + "signature": "AAAA" + }, + "type": "public-key", + "extensions": {} + }) + } +} diff --git a/crates/security/tests/jwks_test.rs b/crates/security/tests/jwks_test.rs index dcc27a46..3001ecf2 100644 --- a/crates/security/tests/jwks_test.rs +++ b/crates/security/tests/jwks_test.rs @@ -277,3 +277,114 @@ async fn rejects_disallowed_algorithm() { let err = verifier.validate(&token).await.unwrap_err(); assert!(err.to_string().contains("not allowed"), "{err}"); } + +// --- H5/H6/H7: EC + EdDSA JWKS keys, nbf validation, clock-skew leeway --- + +/// Static test EC P-256 key (PKCS8) + its public JWK coordinates. +const TEST_EC_PKCS8_PEM: &[u8] = b"-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgGBdQ2xtiX67nEJYc +DmWSCUiQ3aHOBNHgTBVvjpf8VxGhRANCAARo6W+SxA2nlCoQeT+s2sk1OES7Yo8X +4QB9PQLgH//hJLqWWEjx7kiPqlJUPo29nhDWrW5YBtUeJev0rN5+mN14 +-----END PRIVATE KEY----- +"; +const TEST_EC_X: &str = "aOlvksQNp5QqEHk_rNrJNThEu2KPF-EAfT0C4B__4SQ"; +const TEST_EC_Y: &str = "upZYSPHuSI-qUlQ-jb2eENatblgG1R4l6_Ss3n6Y3Xg"; + +/// Static test Ed25519 key (PKCS8) + its public JWK `x` coordinate. +const TEST_ED_PKCS8_PEM: &[u8] = b"-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIDVAlDQGWMmEiBkvbkZ7QK87MiDhldjFnHFW/bBHFziG +-----END PRIVATE KEY----- +"; +const TEST_ED_X: &str = "MMtWyDzux-mxoojPlUC2I0voVab24p-LpXk0GgWcWbc"; + +/// Spawns an in-process JWKS server returning the given document body. +async fn spawn_jwks_server_with(body: Value) -> String { + let app = Router::new().route( + "/.well-known/jwks.json", + get(move || { + let body = body.clone(); + async move { Json(body) } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + format!("http://{addr}/.well-known/jwks.json") +} + +fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() +} + +// H5: an ES256 token signed with an EC key in the JWKS must verify. +#[tokio::test] +async fn validate_es256_token_from_ec_jwks() { + let body = json!({"keys":[ + {"kty":"EC","kid":"ec-kid","crv":"P-256","x":TEST_EC_X,"y":TEST_EC_Y} + ]}); + let uri = spawn_jwks_server_with(body).await; + let verifier = JwksVerifier::new(&uri); + + let mut header = Header::new(Algorithm::ES256); + header.kid = Some("ec-kid".to_string()); + let token = jsonwebtoken::encode( + &header, + &json!({"sub": "ec-user", "exp": now() + 3600}), + &EncodingKey::from_ec_pem(TEST_EC_PKCS8_PEM).unwrap(), + ) + .unwrap(); + + let payload = verifier.validate(&token).await.unwrap(); + assert_eq!(payload["sub"], "ec-user"); +} + +// H5: an EdDSA token signed with an OKP key in the JWKS must verify. +#[tokio::test] +async fn validate_eddsa_token_from_okp_jwks() { + let body = json!({"keys":[ + {"kty":"OKP","kid":"ed-kid","crv":"Ed25519","x":TEST_ED_X} + ]}); + let uri = spawn_jwks_server_with(body).await; + let verifier = JwksVerifier::new(&uri); + + let mut header = Header::new(Algorithm::EdDSA); + header.kid = Some("ed-kid".to_string()); + let token = jsonwebtoken::encode( + &header, + &json!({"sub": "ed-user", "exp": now() + 3600}), + &EncodingKey::from_ed_pem(TEST_ED_PKCS8_PEM).unwrap(), + ) + .unwrap(); + + let payload = verifier.validate(&token).await.unwrap(); + assert_eq!(payload["sub"], "ed-user"); +} + +// H7: a token whose `nbf` is in the future (beyond leeway) must be rejected. +#[tokio::test] +async fn validate_rejects_future_nbf() { + let (uri, _) = spawn_jwks_server().await; + let verifier = JwksVerifier::new(&uri); + let token = make_token( + json!({"sub": "u1", "nbf": now() + 3600, "exp": now() + 7200}), + TEST_KID, + ); + let err = verifier.validate(&token).await.unwrap_err(); + assert!(err.to_string().contains("Token validation failed"), "{err}"); +} + +// H6: an `exp` just barely in the past (within the default 60s clock-skew +// leeway) must still be accepted — Spring's JwtTimestampValidator default. +#[tokio::test] +async fn validate_allows_exp_within_clock_skew_leeway() { + let (uri, _) = spawn_jwks_server().await; + let verifier = JwksVerifier::new(&uri); + let token = make_token(json!({"sub": "u1", "exp": now() - 30}), TEST_KID); + let payload = verifier.validate(&token).await.unwrap(); + assert_eq!(payload["sub"], "u1"); +} diff --git a/crates/security/tests/oauth2_login_test.rs b/crates/security/tests/oauth2_login_test.rs index 9afc15cd..7ff6358b 100644 --- a/crates/security/tests/oauth2_login_test.rs +++ b/crates/security/tests/oauth2_login_test.rs @@ -568,6 +568,43 @@ async fn callback_rejects_id_token_with_wrong_nonce() { assert_eq!(body_json(resp).await["error"], "invalid_id_token"); } +// H6: an id_token present in the token response must NEVER be trusted without +// validation. With no JWKS configured we cannot validate it, so the login must +// fail rather than silently fall through to the (unverified) userinfo identity. +#[tokio::test] +async fn callback_rejects_id_token_when_no_jwks_configured() { + let (provider, provider_state) = spawn_provider(json!({"sub": "userinfo-user"})).await; + let reg = registration(&provider, false); // deliberately no jwks_uri + let (app, sessions) = login_app(reg); + let session = sessions.session(); + + app.clone() + .oneshot(get_req("/oauth2/authorization/acme")) + .await + .unwrap(); + let state_param = session.get_attribute(SESSION_KEY_STATE).await.unwrap(); + + let id_token = make_id_token(json!({ + "sub": "oidc-user", + "aud": "cid", + "exp": 9999999999u64, + })); + provider_state + .lock() + .unwrap() + .token_extra + .insert("id_token".into(), json!(id_token)); + + let resp = app + .oneshot(get_req(&format!( + "/login/oauth2/code/acme?code=c&state={state_param}" + ))) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + assert_eq!(body_json(resp).await["error"], "invalid_id_token"); +} + // --------------------------------------------------------------------------- // Logout // --------------------------------------------------------------------------- diff --git a/crates/session-postgres/src/lib.rs b/crates/session-postgres/src/lib.rs index f5110125..08728f33 100644 --- a/crates/session-postgres/src/lib.rs +++ b/crates/session-postgres/src/lib.rs @@ -123,19 +123,27 @@ pub const TABLE: &str = "firefly_session_registry"; pub const DDL: &str = "CREATE TABLE IF NOT EXISTS firefly_session_registry (\n \ session_id TEXT PRIMARY KEY,\n \ principal TEXT NOT NULL,\n \ - created_at BIGINT NOT NULL\n)"; + created_at BIGINT NOT NULL,\n \ + expires_at BIGINT NOT NULL DEFAULT 0\n)"; /// The supporting index on `principal`, created alongside the table — pyfly's /// `_principal_idx`. pub const INDEX_DDL: &str = "CREATE INDEX IF NOT EXISTS firefly_session_registry_principal_idx \ ON firefly_session_registry (principal)"; -/// `INSERT … ON CONFLICT (session_id) DO UPDATE` upsert — pyfly's `register`. +/// Adds the `expires_at` column to a pre-existing table (idempotent), so an +/// older deployment migrates on startup without a manual step. +pub const ALTER_SQL: &str = "ALTER TABLE firefly_session_registry \ + ADD COLUMN IF NOT EXISTS expires_at BIGINT NOT NULL DEFAULT 0"; + +/// `INSERT … ON CONFLICT (session_id) DO UPDATE` upsert — pyfly's `register`, +/// now also recording `expires_at`. pub const UPSERT_SQL: &str = - "INSERT INTO firefly_session_registry (session_id, principal, created_at) \ - VALUES ($1, $2, $3) \ + "INSERT INTO firefly_session_registry (session_id, principal, created_at, expires_at) \ + VALUES ($1, $2, $3, $4) \ ON CONFLICT (session_id) DO UPDATE \ - SET principal = EXCLUDED.principal, created_at = EXCLUDED.created_at"; + SET principal = EXCLUDED.principal, created_at = EXCLUDED.created_at, \ + expires_at = EXCLUDED.expires_at"; /// Single-session delete scoped to the principal — pyfly's `deregister`. pub const DELETE_SQL: &str = @@ -148,6 +156,23 @@ pub const LIST_SQL: &str = "SELECT session_id, created_at FROM firefly_session_r /// Per-principal live-session count — pyfly's `count`. pub const COUNT_SQL: &str = "SELECT COUNT(*) FROM firefly_session_registry WHERE principal = $1"; +/// Deletes rows whose `expires_at` has passed (`0` = never expires). Pruning +/// keeps the per-principal concurrency count accurate as sessions age out, +/// instead of leaking orphaned rows forever (H12). +pub const PRUNE_SQL: &str = + "DELETE FROM firefly_session_registry WHERE expires_at > 0 AND expires_at < $1"; + +/// A suggested registry entry TTL (30 minutes) for [`with_ttl`] — **not** the +/// default. Pruning is opt-in (off by default): the TTL is measured from the +/// session's `created_at`, so it only matches a deployment with an *absolute* +/// session lifetime. Firefly's sessions are *sliding* (idle-based), so a fixed +/// TTL would wrongly prune still-active sessions and under-count +/// `maximumSessions`. Enable pruning with [`with_ttl`] only when you have an +/// absolute cap; otherwise rely on explicit `deregister` (on logout). +/// +/// [`with_ttl`]: PostgresSessionRegistry::with_ttl +pub const DEFAULT_REGISTRY_TTL_MILLIS: i64 = 30 * 60 * 1000; + /// A durable, distributed [`firefly_session::SessionRegistry`] backed by a /// single Postgres table, shared by every application instance. /// @@ -161,6 +186,9 @@ pub struct PostgresSessionRegistry { /// Lazy create-table latch: `false` until the DDL has run once. Guarded by /// `ensure_lock` so two concurrent first calls don't both issue the DDL. ensured: Mutex, + /// Registry entry TTL in millis; an entry is pruned once + /// `created_at + ttl_millis` has passed. `0` disables expiry. + ttl_millis: i64, } /// The set of statements a registry runs, rendered once from a single validated @@ -171,10 +199,12 @@ struct TableSql { table: String, ddl: String, index_ddl: String, + alter: String, upsert: String, delete: String, list: String, count: String, + prune: String, } impl TableSql { @@ -187,16 +217,21 @@ impl TableSql { "CREATE TABLE IF NOT EXISTS {table} (\n \ session_id TEXT PRIMARY KEY,\n \ principal TEXT NOT NULL,\n \ - created_at BIGINT NOT NULL\n)" + created_at BIGINT NOT NULL,\n \ + expires_at BIGINT NOT NULL DEFAULT 0\n)" ), index_ddl: format!( "CREATE INDEX IF NOT EXISTS {table}_principal_idx ON {table} (principal)" ), + alter: format!( + "ALTER TABLE {table} ADD COLUMN IF NOT EXISTS expires_at BIGINT NOT NULL DEFAULT 0" + ), upsert: format!( - "INSERT INTO {table} (session_id, principal, created_at) \ - VALUES ($1, $2, $3) \ + "INSERT INTO {table} (session_id, principal, created_at, expires_at) \ + VALUES ($1, $2, $3, $4) \ ON CONFLICT (session_id) DO UPDATE \ - SET principal = EXCLUDED.principal, created_at = EXCLUDED.created_at" + SET principal = EXCLUDED.principal, created_at = EXCLUDED.created_at, \ + expires_at = EXCLUDED.expires_at" ), delete: format!("DELETE FROM {table} WHERE principal = $1 AND session_id = $2"), list: format!( @@ -204,10 +239,20 @@ impl TableSql { WHERE principal = $1 ORDER BY created_at ASC, session_id ASC" ), count: format!("SELECT COUNT(*) FROM {table} WHERE principal = $1"), + prune: format!("DELETE FROM {table} WHERE expires_at > 0 AND expires_at < $1"), } } } +/// The current wall-clock time in epoch millis (the units the +/// [`SessionRegistry`] contract uses for `created_at`). +fn now_millis() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + impl std::fmt::Debug for PostgresSessionRegistry { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("PostgresSessionRegistry") @@ -286,9 +331,25 @@ impl PostgresSessionRegistry { client, sql, ensured: Mutex::new(false), + // Pruning is OFF by default: a fixed `created_at + ttl` expiry would + // wrongly evict still-active sliding sessions and under-count + // `maximumSessions`. Opt in via `with_ttl` only with an absolute cap. + ttl_millis: 0, } } + /// Enables registry-entry pruning with an **absolute** TTL: a registered + /// session is pruned once `created_at + ttl` has passed (a zero duration — + /// the default — disables pruning, so entries live until explicitly + /// deregistered). Only enable this when your sessions have an absolute + /// lifetime; for sliding (idle-based) sessions, leave it off (see + /// [`DEFAULT_REGISTRY_TTL_MILLIS`] for the rationale). + #[must_use] + pub fn with_ttl(mut self, ttl: std::time::Duration) -> Self { + self.ttl_millis = ttl.as_millis() as i64; + self + } + /// The table this registry targets (the default [`TABLE`] unless built with /// a `_with_table` constructor). #[must_use] @@ -312,7 +373,8 @@ impl PostgresSessionRegistry { Ok(()) } - /// Runs the table + index DDL (no latch handling). + /// Runs the table + index DDL, plus the idempotent `expires_at` column + /// migration for pre-existing tables (no latch handling). async fn run_ddl(&self) -> Result<(), RegistryError> { self.client .batch_execute(&self.sql.ddl) @@ -321,9 +383,31 @@ impl PostgresSessionRegistry { self.client .batch_execute(&self.sql.index_ddl) .await + .map_err(backend_err)?; + self.client + .batch_execute(&self.sql.alter) + .await .map_err(backend_err) } + /// Deletes expired rows assuming the table already exists (hot path: callers + /// have just run `ensure_table`). + async fn prune_now(&self) { + if let Err(e) = self.client.execute(&self.sql.prune, &[&now_millis()]).await { + tracing::warn!(error = %e, "session-postgres: prune of expired sessions failed"); + } + } + + /// Deletes expired registry rows (best-effort). Called automatically by + /// `register` / `count` / `list_sessions`; also exposed so a scheduled job + /// can sweep the table independently. + pub async fn prune_expired(&self) { + if self.ensure_table().await.is_err() { + return; + } + self.prune_now().await; + } + /// Lazily ensures the table exists, exactly once, behind the async latch — /// pyfly's `_ensure_table`. Concurrent first callers serialize on the /// mutex; only the first runs the DDL. @@ -360,13 +444,24 @@ impl SessionRegistry for PostgresSessionRegistry { tracing::warn!(principal, session_id, error = %e, "session-postgres: ensure-table failed; skipping register"); return; } + // Stamp an expiry so an orphaned row (a session that ends without a + // deregister) ages out instead of inflating the count forever (H12). + let expires_at = if self.ttl_millis > 0 { + created_at.saturating_add(self.ttl_millis) + } else { + 0 + }; if let Err(e) = self .client - .execute(&self.sql.upsert, &[&session_id, &principal, &created_at]) + .execute( + &self.sql.upsert, + &[&session_id, &principal, &created_at, &expires_at], + ) .await { tracing::warn!(principal, session_id, error = %e, "session-postgres: register upsert failed; concurrency cap not enforced for this login"); } + self.prune_now().await; } /// `DELETE … WHERE principal = $1 AND session_id = $2` — pyfly's @@ -395,6 +490,7 @@ impl SessionRegistry for PostgresSessionRegistry { tracing::warn!(principal, error = %e, "session-postgres: ensure-table failed; returning empty session list"); return Vec::new(); } + self.prune_now().await; match self.client.query(&self.sql.list, &[&principal]).await { Ok(rows) => rows .iter() @@ -414,6 +510,7 @@ impl SessionRegistry for PostgresSessionRegistry { tracing::warn!(principal, error = %e, "session-postgres: ensure-table failed; returning count 0"); return 0; } + self.prune_now().await; match self.client.query_one(&self.sql.count, &[&principal]).await { Ok(row) => { let n: i64 = row.get(0); @@ -533,9 +630,19 @@ mod tests { assert!(DDL.contains("session_id TEXT PRIMARY KEY")); assert!(DDL.contains("principal TEXT NOT NULL")); assert!(DDL.contains("created_at BIGINT NOT NULL")); + assert!(DDL.contains("expires_at BIGINT NOT NULL DEFAULT 0")); assert_eq!(TABLE, "firefly_session_registry"); } + #[test] + fn expiry_sql_constants_are_well_formed() { + // H12: the migration adds the column idempotently... + assert!(ALTER_SQL.contains("ADD COLUMN IF NOT EXISTS expires_at BIGINT NOT NULL DEFAULT 0")); + // ...and pruning only ever deletes truly-expired rows (0 = never). + assert!(PRUNE_SQL.contains("WHERE expires_at > 0 AND expires_at < $1")); + assert_eq!(DEFAULT_REGISTRY_TTL_MILLIS, 30 * 60 * 1000); + } + #[test] fn index_ddl_targets_principal() { assert!( @@ -549,8 +656,12 @@ mod tests { assert!(UPSERT_SQL.contains("ON CONFLICT (session_id) DO UPDATE")); assert!(UPSERT_SQL.contains("principal = EXCLUDED.principal")); assert!(UPSERT_SQL.contains("created_at = EXCLUDED.created_at")); + assert!(UPSERT_SQL.contains("expires_at = EXCLUDED.expires_at")); assert!( - UPSERT_SQL.contains("$1") && UPSERT_SQL.contains("$2") && UPSERT_SQL.contains("$3") + UPSERT_SQL.contains("$1") + && UPSERT_SQL.contains("$2") + && UPSERT_SQL.contains("$3") + && UPSERT_SQL.contains("$4") ); } @@ -580,10 +691,81 @@ mod tests { assert_eq!(sql.table, TABLE); assert_eq!(sql.ddl, DDL); assert_eq!(sql.index_ddl, INDEX_DDL); + assert_eq!(sql.alter, ALTER_SQL); assert_eq!(sql.upsert, UPSERT_SQL); assert_eq!(sql.delete, DELETE_SQL); assert_eq!(sql.list, LIST_SQL); assert_eq!(sql.count, COUNT_SQL); + assert_eq!(sql.prune, PRUNE_SQL); + } + + // H12 (real Postgres, env-gated): an expired registry entry is pruned so it + // never inflates the per-principal concurrency count. Runs only when + // FIREFLY_TEST_POSTGRES_URL points at a live database. + #[tokio::test] + async fn expired_sessions_are_pruned_from_the_count() { + let Ok(url) = std::env::var("FIREFLY_TEST_POSTGRES_URL") else { + eprintln!("skipping: FIREFLY_TEST_POSTGRES_URL not set"); + return; + }; + // Own table so the GLOBAL prune cannot race other parallel tests. + let now = now_millis(); + let table = format!("fftest_h12_prune_{now}"); + let reg = PostgresSessionRegistry::connect_with_table(&url, &table) + .await + .expect("connect") + .with_ttl(std::time::Duration::from_secs(3600)); + reg.init().await.expect("init"); + + let principal = format!("h12-user-{now}"); + + // A session created now is live; one created 2h ago is already expired + // under the 1h TTL and must not survive. + reg.register(&principal, "s-live", now).await; + reg.register(&principal, "s-old", now - 2 * 3600 * 1000).await; + + assert_eq!( + reg.count(&principal).await, + 1, + "expired session must be pruned from the count" + ); + let sessions = reg.list_sessions(&principal).await; + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].0, "s-live"); + + // Cleanup. + reg.deregister(&principal, "s-live").await; + assert_eq!(reg.count(&principal).await, 0); + } + + // Review fix: pruning is OFF by default, so an "old" session (which under a + // sliding policy may still be active) is NOT pruned — it stays counted + // until explicitly deregistered. This guards against the cap-under-count + // regression a fixed default TTL would cause. + #[tokio::test] + async fn default_registry_does_not_prune_old_sessions() { + let Ok(url) = std::env::var("FIREFLY_TEST_POSTGRES_URL") else { + eprintln!("skipping: FIREFLY_TEST_POSTGRES_URL not set"); + return; + }; + // No with_ttl() -> pruning disabled by default. Own table for isolation. + let now = now_millis(); + let table = format!("fftest_h12_default_{now}"); + let reg = PostgresSessionRegistry::connect_with_table(&url, &table) + .await + .expect("connect"); + reg.init().await.expect("init"); + + let principal = format!("h12-default-{now}"); + reg.register(&principal, "s-old", now - 100 * 3600 * 1000).await; // ~100h ago + assert_eq!( + reg.count(&principal).await, + 1, + "default registry must NOT prune (sliding sessions stay counted)" + ); + + reg.deregister(&principal, "s-old").await; + assert_eq!(reg.count(&principal).await, 0); } #[test] diff --git a/crates/session-postgres/tests/postgres_registry_test.rs b/crates/session-postgres/tests/postgres_registry_test.rs index 8570ca41..83ea862a 100644 --- a/crates/session-postgres/tests/postgres_registry_test.rs +++ b/crates/session-postgres/tests/postgres_registry_test.rs @@ -133,9 +133,14 @@ impl std::ops::Deref for TestRegistry { /// uniquely-named table (dropped when the returned handle is dropped). Does NOT /// call `init()`, so the lazy auto-DDL is exercised on first use. async fn registry(url: &str, table: &str) -> TestRegistry { + // These tests use synthetic (tiny) created_at values for deterministic + // ordering and assert register/list/count/deregister semantics — not entry + // expiry. Disable the TTL so the synthetic timestamps are never pruned; + // expiry is covered by the lib's `expired_sessions_are_pruned_from_the_count`. let r = PostgresSessionRegistry::connect_with_table(url, table) .await - .expect("connect to FIREFLY_TEST_POSTGRES_URL"); + .expect("connect to FIREFLY_TEST_POSTGRES_URL") + .with_ttl(std::time::Duration::ZERO); TestRegistry { registry: r, _guard: TableGuard { @@ -253,7 +258,9 @@ async fn evict_oldest_through_controller() { let r = Arc::new( PostgresSessionRegistry::connect_with_table(&url, &table) .await - .expect("connect to FIREFLY_TEST_POSTGRES_URL"), + .expect("connect to FIREFLY_TEST_POSTGRES_URL") + // Synthetic created_at ordering; expiry disabled (see `registry`). + .with_ttl(std::time::Duration::ZERO), ); let _guard = TableGuard { url: url.clone(), diff --git a/crates/web/src/cors.rs b/crates/web/src/cors.rs index cd96c873..62723360 100644 --- a/crates/web/src/cors.rs +++ b/crates/web/src/cors.rs @@ -206,8 +206,41 @@ impl CorsConfig { fn preflight_explicit_allow_origin(&self) -> bool { !self.allow_all_origins() || self.allow_credentials } + + /// Validates the configuration, rejecting the unsafe combinations Spring + /// Security refuses at startup. Currently: a wildcard origin (`"*"`) with + /// `allow_credentials = true` (the browser ignores it and it is a security + /// foot-gun — configure explicit origins instead). The Rust analog of + /// Spring's `CorsConfiguration.validateAllowCredentials()`. + pub fn validate(&self) -> Result<(), CorsConfigError> { + if self.allow_all_origins() && self.allow_credentials { + return Err(CorsConfigError::WildcardOriginWithCredentials); + } + Ok(()) + } +} + +/// Why a [`CorsConfig`] was rejected at construction. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CorsConfigError { + /// `allowed_origins` is (or contains) a bare `"*"` while + /// `allow_credentials` is `true`. + WildcardOriginWithCredentials, } +impl std::fmt::Display for CorsConfigError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::WildcardOriginWithCredentials => f.write_str( + "CORS: a wildcard origin (\"*\") cannot be combined with \ + allow_credentials=true; configure explicit origins", + ), + } + } +} + +impl std::error::Error for CorsConfigError {} + /// CORS middleware — short-circuits preflight requests and decorates /// cross-origin responses according to a [`CorsConfig`]. Requests /// without an `Origin` header pass through untouched, as do responses @@ -219,10 +252,24 @@ pub struct CorsLayer { impl CorsLayer { /// Builds the layer from `config`. + /// + /// # Panics + /// + /// Panics if `config` is invalid (see [`CorsConfig::validate`], e.g. a + /// wildcard origin with credentials). Use [`try_new`](Self::try_new) to + /// surface that as a recoverable error. pub fn new(config: CorsConfig) -> Self { - Self { + Self::try_new(config).expect("firefly/web: invalid CorsConfig") + } + + /// Builds the layer, returning [`CorsConfigError`] if `config` is invalid + /// — the fail-at-startup-gracefully analog of Spring rejecting an unsafe + /// CORS configuration with an exception. + pub fn try_new(config: CorsConfig) -> Result { + config.validate()?; + Ok(Self { config: Arc::new(config), - } + }) } } diff --git a/crates/web/src/csrf.rs b/crates/web/src/csrf.rs index d72c7904..11d921b2 100644 --- a/crates/web/src/csrf.rs +++ b/crates/web/src/csrf.rs @@ -42,8 +42,38 @@ use sha2::{Digest, Sha256}; use tower::{Layer, Service}; use crate::globs::matches_any; +use crate::headers::request_is_secure; use crate::problem::problem_response; +/// Policy for the `Secure` attribute on the CSRF cookie. +/// +/// Spring's `CookieCsrfTokenRepository` marks the cookie `Secure` only when the +/// request is itself secure; sending `Secure` over plain HTTP makes the browser +/// drop the cookie, so the double-submit pair can never be established (every +/// unsafe request then 403s). [`Auto`](CookieSecure::Auto) reproduces Spring's +/// request-driven behaviour. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum CookieSecure { + /// Mark `Secure` only when the request arrived over HTTPS (default). + #[default] + Auto, + /// Always mark the cookie `Secure` (HTTPS-only deployments). + Always, + /// Never mark the cookie `Secure` (plain-HTTP dev only). + Never, +} + +impl CookieSecure { + /// Resolves whether the cookie should carry `Secure` for `req`. + fn applies(self, req: &Request) -> bool { + match self { + CookieSecure::Auto => request_is_secure(req), + CookieSecure::Always => true, + CookieSecure::Never => false, + } + } +} + /// Name of the cookie that carries the CSRF token — identical across /// the Java, .NET, Go, and Python ports (Angular convention). pub const CSRF_COOKIE_NAME: &str = "XSRF-TOKEN"; @@ -88,13 +118,16 @@ fn default_exclude_patterns() -> Vec { #[derive(Debug, Clone)] pub struct CsrfLayer { exclude_patterns: Arc>, + cookie_secure: CookieSecure, } impl CsrfLayer { - /// Returns the layer with pyfly's default exclude patterns. + /// Returns the layer with pyfly's default exclude patterns and the + /// request-driven [`CookieSecure::Auto`] cookie policy. pub fn new() -> Self { Self { exclude_patterns: Arc::new(default_exclude_patterns()), + cookie_secure: CookieSecure::Auto, } } @@ -108,6 +141,13 @@ impl CsrfLayer { self.exclude_patterns = Arc::new(patterns.into_iter().map(Into::into).collect()); self } + + /// Sets the `Secure`-attribute policy for the CSRF cookie + /// (default [`CookieSecure::Auto`]). + pub fn cookie_secure(mut self, policy: CookieSecure) -> Self { + self.cookie_secure = policy; + self + } } impl Default for CsrfLayer { @@ -123,6 +163,7 @@ impl Layer for CsrfLayer { CsrfService { inner, exclude_patterns: Arc::clone(&self.exclude_patterns), + cookie_secure: self.cookie_secure, } } } @@ -132,6 +173,7 @@ impl Layer for CsrfLayer { pub struct CsrfService { inner: S, exclude_patterns: Arc>, + cookie_secure: CookieSecure, } /// Extracts the value of `name` from the request's `Cookie` header(s). @@ -149,10 +191,14 @@ fn cookie_value(req: &Request, name: &str) -> Option { } /// Appends the `XSRF-TOKEN` cookie — readable by JavaScript (no -/// `HttpOnly`), `SameSite=Lax`, `Secure`, path `/` — matching pyfly's -/// `_set_csrf_cookie` attribute-for-attribute. -fn set_csrf_cookie(res: &mut Response, token: &str) { - let cookie = format!("{CSRF_COOKIE_NAME}={token}; Path=/; SameSite=Lax; Secure"); +/// `HttpOnly`), `SameSite=Lax`, path `/`, and `Secure` only when `secure` +/// (so the double-submit pair also works over plain-HTTP development; see +/// [`CookieSecure`]). +fn set_csrf_cookie(res: &mut Response, token: &str, secure: bool) { + let mut cookie = format!("{CSRF_COOKIE_NAME}={token}; Path=/; SameSite=Lax"); + if secure { + cookie.push_str("; Secure"); + } if let Ok(value) = HeaderValue::from_str(&cookie) { res.headers_mut().append(header::SET_COOKIE, value); } @@ -185,6 +231,9 @@ where } let method = req.method().clone(); + // Resolve the `Secure` cookie attribute from the request (before `req` + // is moved into the response future). + let secure = self.cookie_secure.applies(&req); // Safe methods — pass through and set/refresh the CSRF cookie. if matches!( @@ -193,7 +242,7 @@ where ) { return Box::pin(async move { let mut res = inner.call(req).await?; - set_csrf_cookie(&mut res, &generate_csrf_token()); + set_csrf_cookie(&mut res, &generate_csrf_token(), secure); Ok(res) }); } @@ -226,8 +275,55 @@ where } // Valid — proceed and rotate the token. let mut res = inner.call(req).await?; - set_csrf_cookie(&mut res, &generate_csrf_token()); + set_csrf_cookie(&mut res, &generate_csrf_token(), secure); Ok(res) }) } } + +#[cfg(test)] +mod tests { + use super::*; + use tower::ServiceExt; + + async fn set_cookie_for(layer: CsrfLayer, forwarded_proto: Option<&str>) -> String { + let inner = tower::service_fn(|_r: Request| async { + Ok::(Response::new(Body::empty())) + }); + let svc = layer.layer(inner); + let mut b = Request::builder().method(Method::GET).uri("/page"); + if let Some(p) = forwarded_proto { + b = b.header("x-forwarded-proto", p); + } + let resp = svc.oneshot(b.body(Body::empty()).unwrap()).await.unwrap(); + resp.headers() + .get(header::SET_COOKIE) + .expect("XSRF cookie set on safe request") + .to_str() + .unwrap() + .to_string() + } + + // H4: Auto policy follows the request scheme — no `Secure` over plain HTTP + // (so the double-submit pair works in dev), `Secure` over HTTPS. + #[tokio::test] + async fn cookie_secure_auto_follows_request_scheme() { + let http = set_cookie_for(CsrfLayer::new(), None).await; + assert!(http.contains("XSRF-TOKEN="), "{http}"); + assert!(!http.contains("Secure"), "HTTP cookie must not be Secure: {http}"); + + let https = set_cookie_for(CsrfLayer::new(), Some("https")).await; + assert!(https.contains("Secure"), "HTTPS cookie must be Secure: {https}"); + } + + // H4: Always/Never override the request scheme. + #[tokio::test] + async fn cookie_secure_always_and_never_override() { + let always = set_cookie_for(CsrfLayer::new().cookie_secure(CookieSecure::Always), None).await; + assert!(always.contains("Secure"), "{always}"); + + let never = + set_cookie_for(CsrfLayer::new().cookie_secure(CookieSecure::Never), Some("https")).await; + assert!(!never.contains("Secure"), "{never}"); + } +} diff --git a/crates/web/src/headers.rs b/crates/web/src/headers.rs index 6bb7d785..fe67d7ef 100644 --- a/crates/web/src/headers.rs +++ b/crates/web/src/headers.rs @@ -55,6 +55,11 @@ pub struct SecurityHeadersConfig { pub content_security_policy: Option, /// `Permissions-Policy` value; `None` (default) omits the header. pub permissions_policy: Option, + /// Emit `Strict-Transport-Security` even over plain HTTP. Default `false`, + /// matching Spring's `HstsHeaderWriter`, which writes HSTS only on secure + /// requests (sending HSTS over HTTP is meaningless and a deployment-config + /// smell). Set `true` to force the header on every response. + pub hsts_include_insecure: bool, } impl Default for SecurityHeadersConfig { @@ -67,6 +72,7 @@ impl Default for SecurityHeadersConfig { referrer_policy: "strict-origin-when-cross-origin".to_string(), content_security_policy: None, permissions_policy: None, + hsts_include_insecure: false, } } } @@ -77,21 +83,25 @@ impl Default for SecurityHeadersConfig { /// per request. #[derive(Debug, Clone, Default)] pub struct SecurityHeadersLayer { + /// Always-on headers (everything except HSTS). pairs: Arc>, + /// The HSTS header, gated on a secure request unless `hsts_include_insecure`. + hsts: Option<(HeaderName, HeaderValue)>, + hsts_include_insecure: bool, } impl SecurityHeadersLayer { /// Builds the layer from `config`, pre-encoding every header pair. /// Invalid header values (non-ASCII) are skipped rather than /// panicking, since they can only come from user configuration. + /// + /// `Strict-Transport-Security` is held separately: it is emitted only on a + /// secure request (Spring's `HstsHeaderWriter` default) unless + /// [`SecurityHeadersConfig::hsts_include_insecure`] is set. pub fn new(config: SecurityHeadersConfig) -> Self { let mut raw: Vec<(&'static str, &str)> = vec![ ("x-content-type-options", &config.x_content_type_options), ("x-frame-options", &config.x_frame_options), - ( - "strict-transport-security", - &config.strict_transport_security, - ), ("x-xss-protection", &config.x_xss_protection), ("referrer-policy", &config.referrer_policy), ]; @@ -110,8 +120,17 @@ impl SecurityHeadersLayer { )) }) .collect(); + let hsts = if config.strict_transport_security.is_empty() { + None + } else { + HeaderValue::from_str(&config.strict_transport_security) + .ok() + .map(|v| (HeaderName::from_static("strict-transport-security"), v)) + }; Self { pairs: Arc::new(pairs), + hsts, + hsts_include_insecure: config.hsts_include_insecure, } } } @@ -123,6 +142,8 @@ impl Layer for SecurityHeadersLayer { SecurityHeadersService { inner, pairs: Arc::clone(&self.pairs), + hsts: self.hsts.clone(), + hsts_include_insecure: self.hsts_include_insecure, } } } @@ -132,6 +153,8 @@ impl Layer for SecurityHeadersLayer { pub struct SecurityHeadersService { inner: S, pairs: Arc>, + hsts: Option<(HeaderName, HeaderValue)>, + hsts_include_insecure: bool, } impl Service> for SecurityHeadersService @@ -151,12 +174,129 @@ where let clone = self.inner.clone(); let mut inner = std::mem::replace(&mut self.inner, clone); let pairs = Arc::clone(&self.pairs); + let hsts = self.hsts.clone(); + let send_hsts = self.hsts_include_insecure || request_is_secure(&req); Box::pin(async move { let mut res = inner.call(req).await?; for (name, value) in pairs.iter() { res.headers_mut().insert(name.clone(), value.clone()); } + if send_hsts { + if let Some((name, value)) = &hsts { + res.headers_mut().insert(name.clone(), value.clone()); + } + } Ok(res) }) } } + +/// Marker inserted into request extensions by [`serve`](crate::serve) when the +/// framework terminates TLS in-process (so `request_is_secure` recognises a +/// direct HTTPS connection, whose origin-form request URI carries no scheme and +/// whose connection sends no `X-Forwarded-Proto`). +#[derive(Debug, Clone, Copy)] +pub(crate) struct SecureRequest; + +/// Whether the request arrived over a secure (HTTPS) channel — recognised three +/// ways: the in-process-TLS [`SecureRequest`] marker, a TLS-terminating proxy's +/// `X-Forwarded-Proto: https`, or an absolute-form `https` request URI. Shared +/// with the CSRF layer's `Secure`-cookie gating. +pub(crate) fn request_is_secure(req: &Request) -> bool { + // In-process TLS termination (firefly's own `serve`) marks the request. + if req.extensions().get::().is_some() { + return true; + } + if let Some(proto) = req + .headers() + .get("x-forwarded-proto") + .and_then(|v| v.to_str().ok()) + { + if proto + .split(',') + .next() + .map(|s| s.trim().eq_ignore_ascii_case("https")) + .unwrap_or(false) + { + return true; + } + } + req.uri().scheme_str() == Some("https") +} + +#[cfg(test)] +mod tests { + use super::*; + use tower::ServiceExt; + + async fn hsts_header(config: SecurityHeadersConfig, forwarded_proto: Option<&str>) -> bool { + let inner = tower::service_fn(|_req: Request| async { + Ok::(Response::new(Body::empty())) + }); + let svc = SecurityHeadersLayer::new(config).layer(inner); + let mut builder = Request::builder().uri("/x"); + if let Some(proto) = forwarded_proto { + builder = builder.header("x-forwarded-proto", proto); + } + let resp = svc.oneshot(builder.body(Body::empty()).unwrap()).await.unwrap(); + resp.headers().contains_key("strict-transport-security") + } + + // H9: by default HSTS is emitted only on secure requests (Spring's + // HstsHeaderWriter), not over plain HTTP. + #[tokio::test] + async fn hsts_is_secure_only_by_default() { + assert!( + !hsts_header(SecurityHeadersConfig::default(), None).await, + "HSTS must NOT be sent over plain HTTP by default" + ); + assert!( + hsts_header(SecurityHeadersConfig::default(), Some("https")).await, + "HSTS must be sent over HTTPS (X-Forwarded-Proto)" + ); + } + + // Review fix: an in-process-TLS request (SecureRequest marker, no + // X-Forwarded-Proto, no URI scheme) is recognised as secure, so HSTS is + // emitted — previously it was silently dropped on direct-HTTPS deployments. + #[tokio::test] + async fn hsts_present_for_in_app_tls_marker() { + let inner = tower::service_fn(|_req: Request| async { + Ok::(Response::new(Body::empty())) + }); + let svc = SecurityHeadersLayer::new(SecurityHeadersConfig::default()).layer(inner); + let mut req = Request::builder().uri("/x").body(Body::empty()).unwrap(); + req.extensions_mut().insert(SecureRequest); + let resp = svc.oneshot(req).await.unwrap(); + assert!(resp.headers().contains_key("strict-transport-security")); + } + + // H9: opt-in to always emit HSTS, even over plain HTTP. + #[tokio::test] + async fn hsts_can_be_forced_on_insecure() { + let config = SecurityHeadersConfig { + hsts_include_insecure: true, + ..Default::default() + }; + assert!( + hsts_header(config, None).await, + "HSTS must be sent over HTTP when include_insecure is set" + ); + } + + // The other security headers are always present, on HTTP and HTTPS alike. + #[tokio::test] + async fn non_hsts_headers_always_present() { + let inner = tower::service_fn(|_req: Request| async { + Ok::(Response::new(Body::empty())) + }); + let svc = SecurityHeadersLayer::new(SecurityHeadersConfig::default()).layer(inner); + let resp = svc + .oneshot(Request::builder().uri("/x").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert!(resp.headers().contains_key("x-content-type-options")); + assert!(resp.headers().contains_key("x-frame-options")); + assert!(resp.headers().contains_key("referrer-policy")); + } +} diff --git a/crates/web/src/lib.rs b/crates/web/src/lib.rs index 8dd68c39..32f407ea 100644 --- a/crates/web/src/lib.rs +++ b/crates/web/src/lib.rs @@ -145,10 +145,10 @@ pub use correlation::{ CorrelationLayer, CorrelationService, HEADER_REQUEST_ID, HEADER_TENANT_ID, HEADER_TRACEPARENT, HEADER_TRACESTATE, HEADER_TRANSACTION_ID, }; -pub use cors::{CorsConfig, CorsLayer, CorsService, PERMIT_DEFAULT_METHODS}; +pub use cors::{CorsConfig, CorsConfigError, CorsLayer, CorsService, PERMIT_DEFAULT_METHODS}; pub use csrf::{ - generate_csrf_token, validate_csrf_token, CsrfLayer, CsrfService, CSRF_COOKIE_NAME, - CSRF_HEADER_NAME, CSRF_SAFE_METHODS, + generate_csrf_token, validate_csrf_token, CookieSecure, CsrfLayer, CsrfService, + CSRF_COOKIE_NAME, CSRF_HEADER_NAME, CSRF_SAFE_METHODS, }; pub use exception_handler::{ ExceptionAdviceLayer, ExceptionAdviceService, ExceptionHandlerRegistry, diff --git a/crates/web/src/server.rs b/crates/web/src/server.rs index 54e7c9ef..9ad096ec 100644 --- a/crates/web/src/server.rs +++ b/crates/web/src/server.rs @@ -278,6 +278,15 @@ impl Server { Some(max) => router.layer(GlobalConcurrencyLimitLayer::new(max)), None => router, }; + // When we terminate TLS in-process, mark every request as secure so the + // HSTS / CSRF-`Secure`-cookie gating (`request_is_secure`) recognises the + // connection — a direct HTTPS request carries no `X-Forwarded-Proto` and + // its origin-form URI has no scheme. + let app = if properties.tls.is_some() { + app.layer(axum::Extension(crate::headers::SecureRequest)) + } else { + app + }; let make_service = app.into_make_service(); let handle = Handle::new(); diff --git a/crates/web/tests/pyfly_parity_test.rs b/crates/web/tests/pyfly_parity_test.rs index 224e69a5..74429336 100644 --- a/crates/web/tests/pyfly_parity_test.rs +++ b/crates/web/tests/pyfly_parity_test.rs @@ -220,28 +220,26 @@ async fn cors_wildcard_origin_without_credentials_reflects_star() { ); } +// H11: a wildcard origin (`"*"`, the default) combined with credentials is a +// security foot-gun the browser ignores; Spring rejects it at config time. +// Firefly now does too — `try_new` errors and `new` panics — while an explicit +// origin + credentials is accepted (covered by other tests). (Previously this +// silently echoed the origin, Starlette-style.) #[tokio::test] -async fn cors_wildcard_with_credentials_echoes_origin() { - let app = hello_router().layer(CorsLayer::new(CorsConfig { +async fn cors_wildcard_with_credentials_is_rejected() { + let result = CorsLayer::try_new(CorsConfig { allow_credentials: true, ..CorsConfig::default() - })); - let req = Request::builder() - .uri("/hello") - .header(header::ORIGIN, "http://app.test") - .body(Body::empty()) - .unwrap(); - let (_, headers, _) = send(app, req).await; - assert_eq!( - headers.get(header::ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(), - "http://app.test" - ); - assert_eq!( - headers - .get(header::ACCESS_CONTROL_ALLOW_CREDENTIALS) - .unwrap(), - "true" - ); + }); + assert!(result.is_err(), "wildcard origin + credentials must be rejected"); + + // An explicit origin + credentials is fine. + let ok = CorsLayer::try_new(CorsConfig { + allowed_origins: vec!["http://app.test".into()], + allow_credentials: true, + ..CorsConfig::default() + }); + assert!(ok.is_ok()); } #[tokio::test] @@ -325,13 +323,15 @@ async fn cors_preflight_allows_safelisted_header_with_explicit_allow_list() { assert_eq!(String::from_utf8(body).unwrap(), "Disallowed CORS headers"); } -// Bug 2: wildcard origins + credentials echoes the specific request -// origin on preflight, and MUST carry `Vary: Origin` (Starlette's -// preflight_explicit_allow_origin = not allow_all_origins or -// allow_credentials). +// An *explicit* origin + credentials echoes the request origin on preflight +// and MUST carry `Vary: Origin` (Starlette's preflight_explicit_allow_origin). +// (Previously this used the wildcard-origin default + credentials, which H11 +// now rejects; switched to an explicit origin to keep covering the echo+Vary +// path with a valid configuration.) #[tokio::test] -async fn cors_preflight_wildcard_with_credentials_adds_vary_origin() { +async fn cors_preflight_explicit_origin_with_credentials_adds_vary_origin() { let app = hello_router().layer(CorsLayer::new(CorsConfig { + allowed_origins: vec!["http://app.test".into()], allow_credentials: true, ..CorsConfig::default() })); @@ -417,10 +417,19 @@ async fn cors_preflight_multi_failure_reason_and_headers() { // ========= Security headers (pyfly test_security_headers.py) ========= +fn https_req(uri: &str) -> Request { + Request::builder() + .uri(uri) + .header("x-forwarded-proto", "https") + .body(Body::empty()) + .unwrap() +} + #[tokio::test] async fn security_headers_defaults_applied() { - let app = hello_router().layer(SecurityHeadersLayer::new(SecurityHeadersConfig::default())); - let (status, headers, _) = send(app, get_req("/hello")).await; + let make = || hello_router().layer(SecurityHeadersLayer::new(SecurityHeadersConfig::default())); + // Over HTTPS, all defaults — including HSTS — are present. + let (status, headers, _) = send(make(), https_req("/hello")).await; assert_eq!(status, StatusCode::OK); assert_eq!(headers.get("x-content-type-options").unwrap(), "nosniff"); assert_eq!(headers.get("x-frame-options").unwrap(), "DENY"); @@ -433,6 +442,12 @@ async fn security_headers_defaults_applied() { headers.get("referrer-policy").unwrap(), "strict-origin-when-cross-origin" ); + + // H9: HSTS is omitted over plain HTTP by default (Spring's HstsHeaderWriter); + // the other headers are still present. + let (_, http_headers, _) = send(make(), get_req("/hello")).await; + assert!(http_headers.get("strict-transport-security").is_none()); + assert_eq!(http_headers.get("x-frame-options").unwrap(), "DENY"); } #[tokio::test] @@ -445,7 +460,7 @@ async fn security_headers_custom_config() { ..SecurityHeadersConfig::default() }; let app = hello_router().layer(SecurityHeadersLayer::new(config)); - let (_, headers, _) = send(app, get_req("/hello")).await; + let (_, headers, _) = send(app, https_req("/hello")).await; assert_eq!(headers.get("x-frame-options").unwrap(), "SAMEORIGIN"); assert_eq!( headers.get("strict-transport-security").unwrap(), @@ -517,7 +532,8 @@ fn csrf_token_generation_and_validation() { #[tokio::test] async fn csrf_safe_method_sets_cookie() { - let (status, headers, _) = send(csrf_router(), get_req("/page")).await; + // Over HTTPS the cookie carries Secure (H4 Auto policy). + let (status, headers, _) = send(csrf_router(), https_req("/page")).await; assert_eq!(status, StatusCode::OK); let cookie = csrf_cookie_from(&headers).expect("XSRF-TOKEN cookie set"); assert!(cookie.contains("Path=/")); @@ -531,6 +547,12 @@ async fn csrf_safe_method_sets_cookie() { .trim_start_matches(&format!("{CSRF_COOKIE_NAME}=")) .to_string(); assert_eq!(token.len(), 43); + + // H4: over plain HTTP the cookie omits Secure so the double-submit pair + // can still be established in development. + let (_, http_headers, _) = send(csrf_router(), get_req("/page")).await; + let http_cookie = csrf_cookie_from(&http_headers).expect("XSRF-TOKEN cookie set"); + assert!(!http_cookie.contains("Secure"), "HTTP cookie: {http_cookie}"); } #[tokio::test] diff --git a/docs/book/book-es.yaml b/docs/book/book-es.yaml index b872f933..7bccbeb7 100644 --- a/docs/book/book-es.yaml +++ b/docs/book/book-es.yaml @@ -178,6 +178,11 @@ parts: num: A title: Índice de crates y módulos opener: art/openers/appb.svg + - id: appc + file: 14a-spring-security-parity.md + num: B + title: Paridad con Spring Security + opener: art/openers/appc.svg - id: glossary file: 92-glossary.md num: '' diff --git a/docs/book/book.yaml b/docs/book/book.yaml index 3702abbb..2334ee4e 100644 --- a/docs/book/book.yaml +++ b/docs/book/book.yaml @@ -64,6 +64,7 @@ parts: - title: "Appendices" chapters: - {id: appb, file: 91-appendix-modules.md, num: "A", title: "Crate & Module Index", opener: art/openers/appb.svg} + - {id: appc, file: 14a-spring-security-parity.md, num: "B", title: "Spring Security Parity", opener: art/openers/appc.svg} - {id: glossary, file: 92-glossary.md, num: "", title: "Glossary"} back: [] diff --git a/docs/book/dist/firefly-rust-by-example-es.epub b/docs/book/dist/firefly-rust-by-example-es.epub index b6de9f0e..8653e3b9 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 02aba2ab..66632fb9 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 fc4717e7..35cc7e1f 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 f6df8b48..317988d3 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/14-security.md b/docs/book/src-es/14-security.md index f4ead77e..b782f835 100644 --- a/docs/book/src-es/14-security.md +++ b/docs/book/src-es/14-security.md @@ -866,3 +866,6 @@ métricas y el panel de administración. **[Cableado de dependencias](./04-dependency-wiring.md)**. - Dirige el router cableado en pruebas con `bootstrap()` en **[Pruebas](./18-testing.md)**. +- Consulta el apéndice **[Paridad con Spring Security](./14a-spring-security-parity.md)**: + la matriz completa de cobertura de Spring Security 6, los comportamientos fieles + a Spring y el login sin contraseña (enlaces mágicos de un solo uso + passkeys WebAuthn). diff --git a/docs/book/src-es/14a-spring-security-parity.md b/docs/book/src-es/14a-spring-security-parity.md new file mode 100644 index 00000000..23648e4d --- /dev/null +++ b/docs/book/src-es/14a-spring-security-parity.md @@ -0,0 +1,97 @@ +# Paridad con Spring Security + +Este apéndice relaciona la capa de seguridad de Firefly con **Spring Security 6 +/ Spring Boot 3**: qué está soportado hoy, los comportamientos fieles a Spring +que conviene conocer y la hoja de ruta para el resto. Complementa el capítulo de +[Seguridad](./14-security.md), que es el tutorial práctico. + +La capa de seguridad de Firefly es un port idiomático a Rust — capas `tower` en +lugar de filtros de servlet, *traits* en lugar de interfaces, funciones +constructoras en lugar del DSL `HttpSecurity` — así que *la paridad es +semántica, no literal*. Una función está «presente» cuando ofrece el +comportamiento de Spring, sea cual sea su forma. + +## Cobertura de un vistazo + +| Área | Estado | Notas | +|------|--------|-------| +| Autorización de peticiones HTTP (`FilterChain`, RBAC, jerarquía de roles) | ✅ | Coincidencia por segmentos de ruta, denegar por defecto, gana la primera regla | +| Servidor de recursos Bearer / OAuth2 (JWT) | ✅ | JWKS con RSA + **EC (ES256/384)** + **EdDSA**; validación de `iss`/`aud`/`exp`/`nbf`; tolerancia de reloj de 60 s; *challenge* `WWW-Authenticate` (RFC 6750) | +| JWT simétrico (`JwtService`) | ✅ | HS256/384/512, `exp` obligatorio, tolerancia de reloj | +| Seguridad de método (`#[pre_authorize]` / `#[post_authorize]`) | ✅ | Funciona igual con autenticación **bearer *y* de sesión/OAuth2-login** | +| Comprobación de roles (`hasRole`) | ✅ | Acepta el prefijo `ROLE_` de Spring *y* nombres de rol sin prefijo | +| CORS | ✅ | Rechaza la combinación insegura de origen comodín + credenciales | +| Cabeceras de respuesta de seguridad | ✅ | HSTS, CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy; **HSTS solo en peticiones seguras** por defecto | +| CSRF (cookie de doble envío) | ✅ | El atributo `Secure` sigue el esquema de la petición; *bypass* para Bearer | +| Gestión de sesiones | ✅ | Rotación anti-fijación, control de concurrencia, registros distribuidos (Redis / **Postgres, con purga por TTL** / Mongo) | +| Codificación de contraseñas | ✅ | BCrypt + Argon2id; login en tiempo constante (sin oráculo temporal de enumeración de usuarios) | +| Login OAuth2 / OIDC | ✅ | Código de autorización + PKCE + state/nonce; **el `id_token` siempre se valida** (nunca se omite en silencio) | +| Login con token de un solo uso (enlace mágico) | ✅ | `oneTimeTokenLogin()` de Spring 6.4 — `OneTimeTokenService` + manejador de entrega + `/ott/generate` + `/login/ott` | +| WebAuthn / passkeys | 🧩 | `webAuthn()` de Spring 6.4 — módulo `webauthn` opcional (ceremonias de registro y autenticación) | +| Adaptadores de IdP | ✅ | Internal-DB, Keycloak, Azure AD / Entra, AWS Cognito | +| Arquitectura de autenticación (`AuthenticationManager`, `SecurityContextRepository`) | 🚧 | Primitivas presentes; abstracción completa de proveedor/gestor en la hoja de ruta | +| Codificador de contraseñas delegado (migración `{id}`) | 🚧 | Hoja de ruta | +| Form login / HTTP Basic / remember-me | 🚧 | Hoja de ruta | +| Cliente OAuth2 (`AuthorizedClientManager`) / Servidor de autorización | 🚧 | Lado de login presente; cliente saliente y servidor de autorización montado en la hoja de ruta | +| ACL / seguridad de objetos de dominio · SAML2 · LDAP/AD | 🚧 | Hoja de ruta (crates opcionales) | + +## Comportamientos fieles a Spring que conviene conocer + +Coinciden con los valores por defecto de Spring Security 6 y pueden diferir de +un port ingenuo — cada uno tiene una vía de escape por configuración: + +- **`hasRole('ADMIN')` coincide con la autoridad `ROLE_ADMIN`.** Un principal de + Spring o JWT con autoridades prefijadas con `ROLE_` autoriza sin que tengas + que quitar prefijos a mano; los nombres de rol sin prefijo siguen funcionando. +- **La seguridad de método funciona tras cualquier mecanismo de autenticación.** + Un usuario autenticado por sesión u OAuth2-login satisface `#[pre_authorize]` + / `current_authentication()`, no solo el portador de un token bearer. +- **HSTS se envía solo en peticiones seguras** (valor por defecto de + `HstsHeaderWriter`). Configura `hsts_include_insecure` para forzarlo. +- **La cookie CSRF es `Secure` solo cuando la petición es segura**, de modo que + el par de doble envío también funciona en desarrollo local sobre HTTP. +- **Un origen comodín de CORS combinado con credenciales se rechaza** en la + construcción (`CorsLayer::try_new` devuelve un error) — usa orígenes + explícitos. +- **La validación JWT/JWKS tolera 60 s de desfase de reloj** y valida `nbf`; las + claves JWKS EC y EdDSA se verifican, no solo RSA. +- **Un `id_token` de OIDC nunca se confía sin validación** — si no puede + verificarse, el login falla en vez de recurrir a userinfo. +- **Las reglas de autorización por prefijo de ruta respetan los segmentos**: + `permit("/api")` coincide con `/api` y `/api/...` pero no con `/api-internal`. +- **El login con usuario desconocido consume un tiempo de bcrypt comparable** al + de una contraseña incorrecta, cerrando el oráculo temporal de enumeración. + +## Login sin contraseña + +Firefly incluye los dos mecanismos sin contraseña de Spring Security 6.4: + +- **Token de un solo uso (enlace mágico)** — `ott_login_routes` expone + `POST /ott/generate` (acuña un token de un solo uso con caducidad y lo entrega + a tu manejador) y `GET /login/ott?token=…` (lo canjea, rota la sesión y + establece el contexto de seguridad). El manejador por defecto solo registra + que se emitió un token — conecta un manejador real de email/SMS en producción. +- **WebAuthn / passkeys** — el módulo `webauthn` opcional ofrece las ceremonias + de registro y autenticación (`/webauthn/register/options`, + `/webauthn/register`, `/webauthn/authenticate/options`, `/login/webauthn`) + sobre `webauthn-rs`, almacenando credenciales mediante un repositorio + conectable. + +## Hoja de ruta + +La paridad se entrega por niveles, cada uno un incremento: + +1. **Endurecimiento (hecho)** — los comportamientos fieles a Spring anteriores. +2. **Columna vertebral de autenticación** — `AuthenticationManager` / + `ProviderManager`, `SecurityContextRepository`, `DelegatingPasswordEncoder`, + indicadores de estado completos de `UserDetails`, eventos de autenticación, + manejadores conectables de entry-point / access-denied. +3. **Mecanismos web** — form login, HTTP Basic, remember-me, `RequestCache`, + `SessionCreationPolicy`, múltiples cadenas de filtros. +4. **Profundidad de seguridad de método** — enlace de argumentos/principal estilo + SpEL, `@PreFilter`/`@PostFilter`, `PermissionEvaluator`. +5. **Ecosistema OAuth2** — introspección de tokens opacos, gestor de clientes + autorizados salientes, logout iniciado por RP, servidor de autorización + montado. +6. **Subsistemas grandes** — ACL / seguridad de objetos de dominio, LDAP / + Active Directory, SAML2. diff --git a/docs/book/src/14-security.md b/docs/book/src/14-security.md index 7ae51a32..36117878 100644 --- a/docs/book/src/14-security.md +++ b/docs/book/src/14-security.md @@ -827,3 +827,6 @@ admin dashboard. - Revisit how the framework discovers and wires beans like the `FilterChain` in **[Dependency Wiring](./04-dependency-wiring.md)**. - Drive the wired router in tests with `bootstrap()` in **[Testing](./18-testing.md)**. +- See the **[Spring Security Parity](./14a-spring-security-parity.md)** appendix + for the full Spring Security 6 coverage matrix, the Spring-faithful behaviours, + and passwordless login (one-time-token magic links + WebAuthn passkeys). diff --git a/docs/book/src/14a-spring-security-parity.md b/docs/book/src/14a-spring-security-parity.md new file mode 100644 index 00000000..bf195176 --- /dev/null +++ b/docs/book/src/14a-spring-security-parity.md @@ -0,0 +1,94 @@ +# Spring Security Parity + +This appendix maps Firefly's security tier onto **Spring Security 6 / Spring +Boot 3**: what is supported today, the Spring-faithful behaviours you should +know about, and the roadmap for the rest. It complements the +[Security](./14-security.md) chapter, which is the hands-on tutorial. + +Firefly's security tier is an idiomatic Rust port — `tower` layers instead of +servlet filters, traits instead of interfaces, builder functions instead of the +`HttpSecurity` DSL — so *parity is semantic, not literal*. A feature is +"present" when it delivers Spring's behaviour, regardless of shape. + +## Coverage at a glance + +| Area | Status | Notes | +|------|--------|-------| +| HTTP request authorization (`FilterChain`, RBAC, role hierarchy) | ✅ | Path-segment-aware matching, deny-by-default, first-match-wins | +| Bearer / OAuth2 resource server (JWT) | ✅ | JWKS with RSA + **EC (ES256/384)** + **EdDSA**; `iss`/`aud`/`exp`/`nbf` validation; 60 s clock-skew leeway; RFC 6750 `WWW-Authenticate` challenge | +| Symmetric JWT (`JwtService`) | ✅ | HS256/384/512, `exp` required, clock-skew leeway | +| Method security (`#[pre_authorize]` / `#[post_authorize]`) | ✅ | Works uniformly across **bearer *and* session/OAuth2-login** auth | +| Role checks (`hasRole`) | ✅ | Accepts Spring's `ROLE_` prefix *and* bare role names | +| CORS | ✅ | Rejects the unsafe wildcard-origin + credentials combination | +| Security response headers | ✅ | HSTS, CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy; **HSTS is secure-request-only** by default | +| CSRF (double-submit cookie) | ✅ | `Secure` cookie follows the request scheme; Bearer bypass | +| Session management | ✅ | Fixation rotation, concurrency control, distributed registries (Redis / **Postgres, with TTL pruning** / Mongo) | +| Password encoding | ✅ | BCrypt + Argon2id; constant-time login (no user-enumeration timing oracle) | +| OAuth2 / OIDC login | ✅ | Auth-code + PKCE + state/nonce; **`id_token` is always validated** (never silently skipped) | +| One-time-token login (magic link) | ✅ | Spring 6.4 `oneTimeTokenLogin()` — `OneTimeTokenService` + delivery handler + `/ott/generate` + `/login/ott` | +| WebAuthn / passkeys | 🧩 | Spring 6.4 `webAuthn()` — feature-gated `webauthn` module (registration + authentication ceremonies) | +| IdP adapters | ✅ | Internal-DB, Keycloak, Azure AD / Entra, AWS Cognito | +| Authentication architecture (`AuthenticationManager`, `SecurityContextRepository`) | 🚧 | Core primitives present; full provider/manager abstraction on the roadmap | +| Delegating password encoder (`{id}` migration) | 🚧 | Roadmap | +| Form login / HTTP Basic / remember-me | 🚧 | Roadmap | +| OAuth2 client (`AuthorizedClientManager`) / Authorization Server | 🚧 | Login side present; outbound client + a mounted authorization server on the roadmap | +| ACL / domain-object security · SAML2 · LDAP/AD | 🚧 | Roadmap (opt-in crates) | + +## Spring-faithful behaviours to know + +These match Spring Security 6 defaults and may differ from a naïve port — each +has a configuration escape hatch: + +- **`hasRole('ADMIN')` matches the authority `ROLE_ADMIN`.** A ported Spring or + JWT principal carrying `ROLE_`-prefixed authorities authorizes without you + hand-stripping prefixes; bare role names keep working too. +- **Method security works behind every authentication mechanism.** A + session-authenticated or OAuth2-login user satisfies `#[pre_authorize]` / + `current_authentication()`, not only a bearer-token caller. +- **HSTS is sent only over secure requests** (`HstsHeaderWriter` default). + Configure `hsts_include_insecure` to force it. +- **The CSRF cookie is `Secure` only when the request is secure**, so the + double-submit pair also works over plain-HTTP local development. +- **A wildcard CORS origin combined with credentials is rejected** at + construction (`CorsLayer::try_new` returns an error) — use explicit origins. +- **JWT/JWKS validation tolerates 60 s of clock skew** and validates `nbf`; + EC and EdDSA JWKS keys verify, not just RSA. +- **An OIDC `id_token` is never trusted without validation** — if it cannot be + verified the login fails rather than falling through to userinfo. +- **Path-prefix authorization rules are segment-aware**: `permit("/api")` + matches `/api` and `/api/...` but not `/api-internal`. +- **Unknown-username login spends comparable bcrypt time** to a wrong password, + closing the user-enumeration timing oracle. + +## Passwordless login + +Firefly ships the two Spring Security 6.4 passwordless mechanisms: + +- **One-time token (magic link)** — `ott_login_routes` exposes + `POST /ott/generate` (mints a single-use, expiring token and hands it to your + delivery handler) and `GET /login/ott?token=…` (redeems it, rotates the + session, and establishes the security context). The default handler logs only + that a token was issued — wire a real email/SMS handler in production. +- **WebAuthn / passkeys** — the feature-gated `webauthn` module provides the + registration and authentication ceremonies (`/webauthn/register/options`, + `/webauthn/register`, `/webauthn/authenticate/options`, `/login/webauthn`) + built on `webauthn-rs`, storing credentials through a pluggable repository. + +## Roadmap + +Parity is delivered in tiers, each its own increment: + +1. **Hardening (done)** — the Spring-faithful behaviours above. +2. **Authentication spine** — `AuthenticationManager` / `ProviderManager`, + `SecurityContextRepository`, `DelegatingPasswordEncoder`, full `UserDetails` + status flags, authentication events, pluggable entry-point / access-denied + handlers. +3. **Web mechanisms** — form login, HTTP Basic, remember-me, `RequestCache`, + `SessionCreationPolicy`, multiple filter chains. +4. **Method-security depth** — SpEL-style argument/principal binding, + `@PreFilter`/`@PostFilter`, `PermissionEvaluator`. +5. **OAuth2 ecosystem** — opaque-token introspection, the outbound + authorized-client manager, RP-initiated logout, a mounted authorization + server. +6. **Big subsystems** — ACL / domain-object security, LDAP / Active Directory, + SAML2. diff --git a/docs/book/src/SUMMARY.md b/docs/book/src/SUMMARY.md index 819abf58..43a5b111 100644 --- a/docs/book/src/SUMMARY.md +++ b/docs/book/src/SUMMARY.md @@ -43,4 +43,5 @@ # Appendices - [Module Index](./91-appendix-modules.md) +- [Spring Security Parity](./14a-spring-security-parity.md) - [Glossary](./92-glossary.md)