diff --git a/CHANGELOG.md b/CHANGELOG.md index ae3afae..06c157b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,59 @@ All notable changes to the Firefly Framework for Rust. +## v26.6.33 — 2026-06-19 + +**Spring Security parity — Tier 4: the OAuth2 ecosystem.** The wider OAuth2 +surface beyond the browser login flow. All additive (no behaviour change to +existing code). Adversarially reviewed before release. + +### Added + +- **Opaque-token introspection (RFC 7662)** — `RemoteTokenIntrospector` + (Spring's `OpaqueTokenIntrospector`): POSTs a non-JWT bearer token to the + authorization server's introspection endpoint (HTTP Basic client auth) and, + on `active: true`, maps the response to an `Authentication`. Implements + `Verifier`, so it drops into a `BearerLayer` as an alternative to local JWT + verification. Fails closed (transport error / non-2xx / non-JSON / + `active: false`/absent all reject). +- **Outbound OAuth2 client (`AuthorizedClientManager`)** — + `OAuth2AuthorizedClientManager` + `OAuth2AuthorizedClientService` (+ + `InMemoryOAuth2AuthorizedClientService`) obtain, cache, and auto-refresh the + access tokens the app needs to call downstream services: the client-credentials + grant (service-to-service) and the refresh-token grant, reusing a cached + `OAuth2AuthorizedClient` until it is within the clock-skew window of expiry. +- **RP-initiated logout (OIDC)** — `oidc_logout_url` + `ClientRegistration`'s new + `end_session_endpoint` / `post_logout_redirect_uri`: `POST /logout` invalidates + the local session and, when the provider advertises an `end_session_endpoint`, + redirects to it with `id_token_hint` + `post_logout_redirect_uri` (Spring's + `OidcClientInitiatedLogoutSuccessHandler`). The login callback now stores the + `registration_id` + `id_token` for the hint. +- **Authorization-server HTTP endpoints** — `AuthorizationServerRouter` mounts + the previously callable-only `AuthorizationServer` as `POST /oauth2/token` + (RFC 6749; client-credentials + refresh-token, `client_secret_post`; RFC 6749 + §5.2 error envelope) and `GET /.well-known/oauth-authorization-server` (RFC 8414 + metadata). + +### Security notes & known limitations (roadmap) + +- The OAuth2 HTTP clients (introspection, outbound token, JWKS, login) now apply + connect/read timeouts and cap the response body, so a slow or hostile endpoint + cannot hang the bearer-verification path or force unbounded allocation; a token + response with no `expires_in` is assumed short-lived (bounded fallback), never + immortal. `ClientRegistration` and `OAuth2AuthorizedClient` redact their + secrets/tokens in `Debug`. +- The authorization server signs HS256 (symmetric), so no `jwks_uri` is + published; the server-side **authorization_code grant + PKCE**, an `/authorize` + endpoint, and a client-authenticated `/oauth2/revoke` (RFC 7009) remain a + follow-up. +- `OAuth2AuthorizedClientManager` does not single-flight concurrent + authorizations for the same registration: concurrent callers may each hit the + token endpoint, and against an authorization server that *rotates* refresh + tokens, concurrent refreshes can lose a rotated token (last-writer-wins). + Serialize refreshes for the same client if your AS rotates. Token-endpoint + failures surface as the HTTP status (the structured RFC 6749 §5.2 error body is + not yet parsed back). + ## v26.6.32 — 2026-06-19 **Spring Security parity — Tier 3: method-security depth.** Expression-based diff --git a/Cargo.lock b/Cargo.lock index 8ba3e78..89f9784 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1495,7 +1495,7 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "firefly" -version = "26.6.32" +version = "26.6.33" dependencies = [ "axum", "firefly-actuator", @@ -1539,7 +1539,7 @@ dependencies = [ [[package]] name = "firefly-actuator" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "axum", @@ -1556,7 +1556,7 @@ dependencies = [ [[package]] name = "firefly-admin" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "axum", @@ -1585,7 +1585,7 @@ dependencies = [ [[package]] name = "firefly-aop" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "inventory", @@ -1595,7 +1595,7 @@ dependencies = [ [[package]] name = "firefly-backoffice" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "axum", @@ -1614,7 +1614,7 @@ dependencies = [ [[package]] name = "firefly-cache" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "firefly-observability", @@ -1626,7 +1626,7 @@ dependencies = [ [[package]] name = "firefly-cache-postgres" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "chrono", @@ -1638,7 +1638,7 @@ dependencies = [ [[package]] name = "firefly-cache-redis" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "firefly-cache", @@ -1650,7 +1650,7 @@ dependencies = [ [[package]] name = "firefly-callbacks" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "axum", @@ -1676,7 +1676,7 @@ dependencies = [ [[package]] name = "firefly-cli" -version = "26.6.32" +version = "26.6.33" dependencies = [ "axum", "chrono", @@ -1697,7 +1697,7 @@ dependencies = [ [[package]] name = "firefly-client" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-stream", "axum", @@ -1719,7 +1719,7 @@ dependencies = [ [[package]] name = "firefly-config" -version = "26.6.32" +version = "26.6.33" dependencies = [ "axum", "regex", @@ -1734,7 +1734,7 @@ dependencies = [ [[package]] name = "firefly-config-server" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "axum", @@ -1750,7 +1750,7 @@ dependencies = [ [[package]] name = "firefly-container" -version = "26.6.32" +version = "26.6.33" dependencies = [ "futures", "inventory", @@ -1761,7 +1761,7 @@ dependencies = [ [[package]] name = "firefly-cqrs" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "chrono", @@ -1784,7 +1784,7 @@ dependencies = [ [[package]] name = "firefly-data" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-stream", "async-trait", @@ -1801,7 +1801,7 @@ dependencies = [ [[package]] name = "firefly-data-mongodb" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-stream", "async-trait", @@ -1819,7 +1819,7 @@ dependencies = [ [[package]] name = "firefly-data-sqlx" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-stream", "async-trait", @@ -1841,7 +1841,7 @@ dependencies = [ [[package]] name = "firefly-ecm" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "chrono", @@ -1857,7 +1857,7 @@ dependencies = [ [[package]] name = "firefly-ecm-esignature-adobe-sign" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "axum", @@ -1874,7 +1874,7 @@ dependencies = [ [[package]] name = "firefly-ecm-esignature-docusign" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "axum", @@ -1891,7 +1891,7 @@ dependencies = [ [[package]] name = "firefly-ecm-esignature-logalty" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "axum", @@ -1908,7 +1908,7 @@ dependencies = [ [[package]] name = "firefly-ecm-storage-aws" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "axum", @@ -1928,7 +1928,7 @@ dependencies = [ [[package]] name = "firefly-ecm-storage-azure" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "axum", @@ -1948,7 +1948,7 @@ dependencies = [ [[package]] name = "firefly-eda" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "base64 0.22.1", @@ -1970,7 +1970,7 @@ dependencies = [ [[package]] name = "firefly-eda-kafka" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "firefly-eda", @@ -1985,7 +1985,7 @@ dependencies = [ [[package]] name = "firefly-eda-postgres" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "chrono", @@ -2003,7 +2003,7 @@ dependencies = [ [[package]] name = "firefly-eda-rabbitmq" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "firefly-eda", @@ -2018,7 +2018,7 @@ dependencies = [ [[package]] name = "firefly-eda-redis" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "firefly-eda", @@ -2034,7 +2034,7 @@ dependencies = [ [[package]] name = "firefly-eventsourcing" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "base64 0.22.1", @@ -2052,7 +2052,7 @@ dependencies = [ [[package]] name = "firefly-i18n" -version = "26.6.32" +version = "26.6.33" dependencies = [ "axum", "http", @@ -2067,7 +2067,7 @@ dependencies = [ [[package]] name = "firefly-idp" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "axum", @@ -2083,7 +2083,7 @@ dependencies = [ [[package]] name = "firefly-idp-aws-cognito" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "axum", @@ -2104,7 +2104,7 @@ dependencies = [ [[package]] name = "firefly-idp-azure-ad" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "axum", @@ -2121,7 +2121,7 @@ dependencies = [ [[package]] name = "firefly-idp-internal-db" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "axum", @@ -2143,7 +2143,7 @@ dependencies = [ [[package]] name = "firefly-idp-keycloak" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "axum", @@ -2160,7 +2160,7 @@ dependencies = [ [[package]] name = "firefly-integration-tests" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "axum", @@ -2195,7 +2195,7 @@ dependencies = [ [[package]] name = "firefly-kernel" -version = "26.6.32" +version = "26.6.33" dependencies = [ "chrono", "serde", @@ -2207,7 +2207,7 @@ dependencies = [ [[package]] name = "firefly-lifecycle" -version = "26.6.32" +version = "26.6.33" dependencies = [ "thiserror 1.0.69", "tokio", @@ -2216,7 +2216,7 @@ dependencies = [ [[package]] name = "firefly-macros" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "axum", @@ -2240,7 +2240,7 @@ dependencies = [ [[package]] name = "firefly-migrations" -version = "26.6.32" +version = "26.6.33" dependencies = [ "chrono", "hex", @@ -2253,7 +2253,7 @@ dependencies = [ [[package]] name = "firefly-notifications" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "chrono", @@ -2268,7 +2268,7 @@ dependencies = [ [[package]] name = "firefly-notifications-firebase" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "axum", @@ -2284,7 +2284,7 @@ dependencies = [ [[package]] name = "firefly-notifications-resend" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "axum", @@ -2301,7 +2301,7 @@ dependencies = [ [[package]] name = "firefly-notifications-sendgrid" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "axum", @@ -2318,7 +2318,7 @@ dependencies = [ [[package]] name = "firefly-notifications-smtp" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "base64 0.22.1", @@ -2335,7 +2335,7 @@ dependencies = [ [[package]] name = "firefly-notifications-twilio" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "axum", @@ -2351,7 +2351,7 @@ dependencies = [ [[package]] name = "firefly-observability" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "chrono", @@ -2375,7 +2375,7 @@ dependencies = [ [[package]] name = "firefly-openapi" -version = "26.6.32" +version = "26.6.33" dependencies = [ "axum", "chrono", @@ -2391,7 +2391,7 @@ dependencies = [ [[package]] name = "firefly-orchestration" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "axum", @@ -2415,7 +2415,7 @@ dependencies = [ [[package]] name = "firefly-plugins" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "chrono", @@ -2425,7 +2425,7 @@ dependencies = [ [[package]] name = "firefly-reactive" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-stream", "firefly-kernel", @@ -2437,7 +2437,7 @@ dependencies = [ [[package]] name = "firefly-resilience" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "firefly-config", @@ -2449,7 +2449,7 @@ dependencies = [ [[package]] name = "firefly-rule-engine" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "axum", @@ -2469,7 +2469,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "axum", @@ -2486,7 +2486,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-core" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "chrono", @@ -2501,7 +2501,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-interfaces" -version = "26.6.32" +version = "26.6.33" dependencies = [ "chrono", "firefly", @@ -2512,7 +2512,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-models" -version = "26.6.32" +version = "26.6.33" dependencies = [ "chrono", "firefly", @@ -2525,7 +2525,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-sdk" -version = "26.6.32" +version = "26.6.33" dependencies = [ "firefly-client", "firefly-sample-lumen-ledger-interfaces", @@ -2537,7 +2537,7 @@ dependencies = [ [[package]] name = "firefly-sample-lumen-ledger-web" -version = "26.6.32" +version = "26.6.33" dependencies = [ "axum", "firefly", @@ -2555,7 +2555,7 @@ dependencies = [ [[package]] name = "firefly-sample-macro-quickstart" -version = "26.6.32" +version = "26.6.33" dependencies = [ "axum", "firefly", @@ -2568,7 +2568,7 @@ dependencies = [ [[package]] name = "firefly-sample-orders" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "axum", @@ -2591,7 +2591,7 @@ dependencies = [ [[package]] name = "firefly-sample-reactive-banking" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-stream", "async-trait", @@ -2631,7 +2631,7 @@ dependencies = [ [[package]] name = "firefly-scheduling" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "chrono", @@ -2652,7 +2652,7 @@ dependencies = [ [[package]] name = "firefly-security" -version = "26.6.32" +version = "26.6.33" dependencies = [ "argon2", "async-trait", @@ -2683,7 +2683,7 @@ dependencies = [ [[package]] name = "firefly-session" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "axum", @@ -2708,7 +2708,7 @@ dependencies = [ [[package]] name = "firefly-session-mongodb" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "firefly-session", @@ -2721,7 +2721,7 @@ dependencies = [ [[package]] name = "firefly-session-postgres" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "firefly-session", @@ -2733,7 +2733,7 @@ dependencies = [ [[package]] name = "firefly-session-redis" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "firefly-session", @@ -2745,7 +2745,7 @@ dependencies = [ [[package]] name = "firefly-shell" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "futures", @@ -2755,7 +2755,7 @@ dependencies = [ [[package]] name = "firefly-spike-linkspike-core" -version = "26.6.32" +version = "26.6.33" dependencies = [ "axum", "firefly", @@ -2763,7 +2763,7 @@ dependencies = [ [[package]] name = "firefly-spike-linkspike-web" -version = "26.6.32" +version = "26.6.33" dependencies = [ "axum", "firefly", @@ -2775,7 +2775,7 @@ dependencies = [ [[package]] name = "firefly-sse" -version = "26.6.32" +version = "26.6.33" dependencies = [ "axum", "bytes", @@ -2791,7 +2791,7 @@ dependencies = [ [[package]] name = "firefly-starter-application" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "firefly-cqrs", @@ -2805,7 +2805,7 @@ dependencies = [ [[package]] name = "firefly-starter-core" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "axum", @@ -2831,7 +2831,7 @@ dependencies = [ [[package]] name = "firefly-starter-data" -version = "26.6.32" +version = "26.6.33" dependencies = [ "axum", "firefly-cqrs", @@ -2844,7 +2844,7 @@ dependencies = [ [[package]] name = "firefly-starter-domain" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "firefly-eventsourcing", @@ -2856,7 +2856,7 @@ dependencies = [ [[package]] name = "firefly-starter-experience" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "axum", @@ -2877,7 +2877,7 @@ dependencies = [ [[package]] name = "firefly-starter-web" -version = "26.6.32" +version = "26.6.33" dependencies = [ "axum", "firefly-kernel", @@ -2892,7 +2892,7 @@ dependencies = [ [[package]] name = "firefly-testkit" -version = "26.6.32" +version = "26.6.33" dependencies = [ "axum", "base64 0.22.1", @@ -2911,7 +2911,7 @@ dependencies = [ [[package]] name = "firefly-transactional" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "inventory", @@ -2922,7 +2922,7 @@ dependencies = [ [[package]] name = "firefly-utils" -version = "26.6.32" +version = "26.6.33" dependencies = [ "aes-gcm", "base64 0.22.1", @@ -2938,7 +2938,7 @@ dependencies = [ [[package]] name = "firefly-validators" -version = "26.6.32" +version = "26.6.33" dependencies = [ "chrono", "firefly-kernel", @@ -2949,7 +2949,7 @@ dependencies = [ [[package]] name = "firefly-web" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "axum", @@ -2988,7 +2988,7 @@ dependencies = [ [[package]] name = "firefly-webhooks" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "axum", @@ -3016,7 +3016,7 @@ dependencies = [ [[package]] name = "firefly-websocket" -version = "26.6.32" +version = "26.6.33" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 08a5c9b..079b268 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,7 +90,7 @@ members = [ ] [workspace.package] -version = "26.6.32" +version = "26.6.33" 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.32" } -firefly-kernel = { path = "crates/kernel", version = "26.6.32" } -firefly-utils = { path = "crates/utils", version = "26.6.32" } -firefly-validators = { path = "crates/validators", version = "26.6.32" } -firefly-web = { path = "crates/web", version = "26.6.32" } -firefly-config = { path = "crates/config", version = "26.6.32" } -firefly-i18n = { path = "crates/i18n", version = "26.6.32" } -firefly-cache = { path = "crates/cache", version = "26.6.32" } -firefly-observability = { path = "crates/observability", version = "26.6.32" } -firefly-data = { path = "crates/data", version = "26.6.32" } -firefly-cqrs = { path = "crates/cqrs", version = "26.6.32" } -firefly-eda = { path = "crates/eda", version = "26.6.32" } -firefly-eventsourcing = { path = "crates/eventsourcing", version = "26.6.32" } -firefly-orchestration = { path = "crates/orchestration", version = "26.6.32" } -firefly-rule-engine = { path = "crates/rule-engine", version = "26.6.32" } -firefly-plugins = { path = "crates/plugins", version = "26.6.32" } -firefly-lifecycle = { path = "crates/lifecycle", version = "26.6.32" } -firefly-actuator = { path = "crates/actuator", version = "26.6.32" } -firefly-scheduling = { path = "crates/scheduling", version = "26.6.32" } -firefly-resilience = { path = "crates/resilience", version = "26.6.32" } -firefly-security = { path = "crates/security", version = "26.6.32" } -firefly-migrations = { path = "crates/migrations", version = "26.6.32" } -firefly-openapi = { path = "crates/openapi", version = "26.6.32" } -firefly-sse = { path = "crates/sse", version = "26.6.32" } -firefly-transactional = { path = "crates/transactional", version = "26.6.32" } -firefly-testkit = { path = "crates/testkit", version = "26.6.32" } -firefly-client = { path = "crates/client", version = "26.6.32" } -firefly-config-server = { path = "crates/config-server", version = "26.6.32" } -firefly-idp = { path = "crates/idp", version = "26.6.32" } -firefly-idp-internal-db = { path = "crates/idp-internal-db", version = "26.6.32" } -firefly-idp-keycloak = { path = "crates/idp-keycloak", version = "26.6.32" } -firefly-idp-azure-ad = { path = "crates/idp-azure-ad", version = "26.6.32" } -firefly-idp-aws-cognito = { path = "crates/idp-aws-cognito", version = "26.6.32" } -firefly-ecm = { path = "crates/ecm", version = "26.6.32" } -firefly-ecm-storage-aws = { path = "crates/ecm-storage-aws", version = "26.6.32" } -firefly-ecm-storage-azure = { path = "crates/ecm-storage-azure", version = "26.6.32" } -firefly-ecm-esignature-docusign = { path = "crates/ecm-esignature-docusign", version = "26.6.32" } -firefly-ecm-esignature-adobe-sign = { path = "crates/ecm-esignature-adobe-sign", version = "26.6.32" } -firefly-ecm-esignature-logalty = { path = "crates/ecm-esignature-logalty", version = "26.6.32" } -firefly-notifications = { path = "crates/notifications", version = "26.6.32" } -firefly-notifications-sendgrid = { path = "crates/notifications-sendgrid", version = "26.6.32" } -firefly-notifications-resend = { path = "crates/notifications-resend", version = "26.6.32" } -firefly-notifications-twilio = { path = "crates/notifications-twilio", version = "26.6.32" } -firefly-notifications-firebase = { path = "crates/notifications-firebase", version = "26.6.32" } -firefly-callbacks = { path = "crates/callbacks", version = "26.6.32" } -firefly-webhooks = { path = "crates/webhooks", version = "26.6.32" } -firefly-starter-core = { path = "crates/starter-core", version = "26.6.32" } -firefly-starter-application = { path = "crates/starter-application", version = "26.6.32" } -firefly-starter-domain = { path = "crates/starter-domain", version = "26.6.32" } -firefly-starter-data = { path = "crates/starter-data", version = "26.6.32" } -firefly-backoffice = { path = "crates/backoffice", version = "26.6.32" } -firefly-admin = { path = "crates/admin", version = "26.6.32" } -firefly-aop = { path = "crates/aop", version = "26.6.32" } -firefly-cli = { path = "crates/cli", version = "26.6.32" } -firefly-container = { path = "crates/container", version = "26.6.32" } -firefly-session = { path = "crates/session", version = "26.6.32" } -firefly-shell = { path = "crates/shell", version = "26.6.32" } -firefly-websocket = { path = "crates/websocket", version = "26.6.32" } -firefly-notifications-smtp = { path = "crates/notifications-smtp", version = "26.6.32" } -firefly-cache-redis = { path = "crates/cache-redis", version = "26.6.32" } -firefly-eda-kafka = { path = "crates/eda-kafka", version = "26.6.32" } -firefly-eda-rabbitmq = { path = "crates/eda-rabbitmq", version = "26.6.32" } -firefly-eda-postgres = { path = "crates/eda-postgres", version = "26.6.32" } -firefly-eda-redis = { path = "crates/eda-redis", version = "26.6.32" } -firefly-cache-postgres = { path = "crates/cache-postgres", version = "26.6.32" } -firefly-starter-web = { path = "crates/starter-web", version = "26.6.32" } -firefly = { path = "crates/firefly", version = "26.6.32" } -firefly-macros = { path = "crates/macros", version = "26.6.32" } -firefly-data-sqlx = { path = "crates/data-sqlx", version = "26.6.32" } -firefly-data-mongodb = { path = "crates/data-mongodb", version = "26.6.32" } -firefly-session-redis = { path = "crates/session-redis", version = "26.6.32" } -firefly-session-postgres = { path = "crates/session-postgres", version = "26.6.32" } -firefly-session-mongodb = { path = "crates/session-mongodb", version = "26.6.32" } -firefly-starter-experience = { path = "crates/starter-experience", version = "26.6.32" } +firefly-reactive = { path = "crates/reactive", version = "26.6.33" } +firefly-kernel = { path = "crates/kernel", version = "26.6.33" } +firefly-utils = { path = "crates/utils", version = "26.6.33" } +firefly-validators = { path = "crates/validators", version = "26.6.33" } +firefly-web = { path = "crates/web", version = "26.6.33" } +firefly-config = { path = "crates/config", version = "26.6.33" } +firefly-i18n = { path = "crates/i18n", version = "26.6.33" } +firefly-cache = { path = "crates/cache", version = "26.6.33" } +firefly-observability = { path = "crates/observability", version = "26.6.33" } +firefly-data = { path = "crates/data", version = "26.6.33" } +firefly-cqrs = { path = "crates/cqrs", version = "26.6.33" } +firefly-eda = { path = "crates/eda", version = "26.6.33" } +firefly-eventsourcing = { path = "crates/eventsourcing", version = "26.6.33" } +firefly-orchestration = { path = "crates/orchestration", version = "26.6.33" } +firefly-rule-engine = { path = "crates/rule-engine", version = "26.6.33" } +firefly-plugins = { path = "crates/plugins", version = "26.6.33" } +firefly-lifecycle = { path = "crates/lifecycle", version = "26.6.33" } +firefly-actuator = { path = "crates/actuator", version = "26.6.33" } +firefly-scheduling = { path = "crates/scheduling", version = "26.6.33" } +firefly-resilience = { path = "crates/resilience", version = "26.6.33" } +firefly-security = { path = "crates/security", version = "26.6.33" } +firefly-migrations = { path = "crates/migrations", version = "26.6.33" } +firefly-openapi = { path = "crates/openapi", version = "26.6.33" } +firefly-sse = { path = "crates/sse", version = "26.6.33" } +firefly-transactional = { path = "crates/transactional", version = "26.6.33" } +firefly-testkit = { path = "crates/testkit", version = "26.6.33" } +firefly-client = { path = "crates/client", version = "26.6.33" } +firefly-config-server = { path = "crates/config-server", version = "26.6.33" } +firefly-idp = { path = "crates/idp", version = "26.6.33" } +firefly-idp-internal-db = { path = "crates/idp-internal-db", version = "26.6.33" } +firefly-idp-keycloak = { path = "crates/idp-keycloak", version = "26.6.33" } +firefly-idp-azure-ad = { path = "crates/idp-azure-ad", version = "26.6.33" } +firefly-idp-aws-cognito = { path = "crates/idp-aws-cognito", version = "26.6.33" } +firefly-ecm = { path = "crates/ecm", version = "26.6.33" } +firefly-ecm-storage-aws = { path = "crates/ecm-storage-aws", version = "26.6.33" } +firefly-ecm-storage-azure = { path = "crates/ecm-storage-azure", version = "26.6.33" } +firefly-ecm-esignature-docusign = { path = "crates/ecm-esignature-docusign", version = "26.6.33" } +firefly-ecm-esignature-adobe-sign = { path = "crates/ecm-esignature-adobe-sign", version = "26.6.33" } +firefly-ecm-esignature-logalty = { path = "crates/ecm-esignature-logalty", version = "26.6.33" } +firefly-notifications = { path = "crates/notifications", version = "26.6.33" } +firefly-notifications-sendgrid = { path = "crates/notifications-sendgrid", version = "26.6.33" } +firefly-notifications-resend = { path = "crates/notifications-resend", version = "26.6.33" } +firefly-notifications-twilio = { path = "crates/notifications-twilio", version = "26.6.33" } +firefly-notifications-firebase = { path = "crates/notifications-firebase", version = "26.6.33" } +firefly-callbacks = { path = "crates/callbacks", version = "26.6.33" } +firefly-webhooks = { path = "crates/webhooks", version = "26.6.33" } +firefly-starter-core = { path = "crates/starter-core", version = "26.6.33" } +firefly-starter-application = { path = "crates/starter-application", version = "26.6.33" } +firefly-starter-domain = { path = "crates/starter-domain", version = "26.6.33" } +firefly-starter-data = { path = "crates/starter-data", version = "26.6.33" } +firefly-backoffice = { path = "crates/backoffice", version = "26.6.33" } +firefly-admin = { path = "crates/admin", version = "26.6.33" } +firefly-aop = { path = "crates/aop", version = "26.6.33" } +firefly-cli = { path = "crates/cli", version = "26.6.33" } +firefly-container = { path = "crates/container", version = "26.6.33" } +firefly-session = { path = "crates/session", version = "26.6.33" } +firefly-shell = { path = "crates/shell", version = "26.6.33" } +firefly-websocket = { path = "crates/websocket", version = "26.6.33" } +firefly-notifications-smtp = { path = "crates/notifications-smtp", version = "26.6.33" } +firefly-cache-redis = { path = "crates/cache-redis", version = "26.6.33" } +firefly-eda-kafka = { path = "crates/eda-kafka", version = "26.6.33" } +firefly-eda-rabbitmq = { path = "crates/eda-rabbitmq", version = "26.6.33" } +firefly-eda-postgres = { path = "crates/eda-postgres", version = "26.6.33" } +firefly-eda-redis = { path = "crates/eda-redis", version = "26.6.33" } +firefly-cache-postgres = { path = "crates/cache-postgres", version = "26.6.33" } +firefly-starter-web = { path = "crates/starter-web", version = "26.6.33" } +firefly = { path = "crates/firefly", version = "26.6.33" } +firefly-macros = { path = "crates/macros", version = "26.6.33" } +firefly-data-sqlx = { path = "crates/data-sqlx", version = "26.6.33" } +firefly-data-mongodb = { path = "crates/data-mongodb", version = "26.6.33" } +firefly-session-redis = { path = "crates/session-redis", version = "26.6.33" } +firefly-session-postgres = { path = "crates/session-postgres", version = "26.6.33" } +firefly-session-mongodb = { path = "crates/session-mongodb", version = "26.6.33" } +firefly-starter-experience = { path = "crates/starter-experience", version = "26.6.33" } # ---- 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 94ef219..7c7dcdb 100644 --- a/MODULES.md +++ b/MODULES.md @@ -48,7 +48,7 @@ declarative macros instead of hand-rolled builder wiring. | [`firefly-actuator`](crates/actuator/README.md) | `/actuator/{health,info,metrics,env,tasks,version}` + liveness/readiness probes, runtime loggers, `httpexchanges`, `threaddump`, labeled Micrometer metrics, `refresh`, `management.endpoints.web` exposure | | [`firefly-scheduling`](crates/scheduling/README.md) | Cron + FixedRate + FixedDelay `Scheduler` | | [`firefly-resilience`](crates/resilience/README.md) | `CircuitBreaker`, `RateLimiter`, `Bulkhead`, `Timeout`, composable `Chain` | -| [`firefly-security`](crates/security/README.md) | `Authentication` extension, `BearerLayer`, RBAC `FilterChain` (`ROLE_`-aware, path-segment-safe), the authentication spine (`AuthenticationManager`/`ProviderManager`, `UserDetails`+`DaoAuthenticationProvider`, `SecurityContextRepository`, `DelegatingPasswordEncoder`), web mechanisms (`httpBasic`, `formLogin`, `TokenBasedRememberMeServices`, `RequestCache`, `SessionCreationPolicy`, `SecurityFilterChains`), method-security depth (`PermissionEvaluator` + `has_permission`, consumed by the expression `#[pre_authorize]`/`#[post_authorize]`/`#[pre_filter]`/`#[post_filter]` macros), JWKS `JwksVerifier` (RSA/EC/EdDSA, `nbf` + clock-skew), `oauth2` (PKCE/OIDC login + 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-security`](crates/security/README.md) | `Authentication` extension, `BearerLayer`, RBAC `FilterChain` (`ROLE_`-aware, path-segment-safe), the authentication spine (`AuthenticationManager`/`ProviderManager`, `UserDetails`+`DaoAuthenticationProvider`, `SecurityContextRepository`, `DelegatingPasswordEncoder`), web mechanisms (`httpBasic`, `formLogin`, `TokenBasedRememberMeServices`, `RequestCache`, `SessionCreationPolicy`, `SecurityFilterChains`), method-security depth (`PermissionEvaluator` + `has_permission`, consumed by the expression `#[pre_authorize]`/`#[post_authorize]`/`#[pre_filter]`/`#[post_filter]` macros), JWKS `JwksVerifier` (RSA/EC/EdDSA, `nbf` + clock-skew), `oauth2` (PKCE/OIDC login + RP-initiated logout, outbound `AuthorizedClientManager`, RFC 7662 opaque-token introspection, authorization server + RFC 8414 `AuthorizationServerRouter`), **one-time-token + WebAuthn/passkey** passwordless login, `RoleHierarchy`, `CsrfLayer`, `PasswordEncoder` (bcrypt + Argon2id) — Spring Security 6-faithful (see the book's *Spring Security Parity* appendix) | | [`firefly-migrations`](crates/migrations/README.md) | Versioned SQL migrations (`V001__init.sql`) over a `Database` port | | [`firefly-openapi`](crates/openapi/README.md) | OpenAPI 3.1 generator + Swagger-UI shim | | [`firefly-sse`](crates/sse/README.md) | Server-Sent Events writer w/ heartbeat + Last-Event-Id | diff --git a/crates/security/src/lib.rs b/crates/security/src/lib.rs index 3dd1b5e..c412448 100644 --- a/crates/security/src/lib.rs +++ b/crates/security/src/lib.rs @@ -243,3 +243,21 @@ pub use webauthn::{ /// Framework version stamp. pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Builds the `reqwest` client used for the security tier's outbound calls +/// (OAuth2 token / introspection / userinfo / JWKS endpoints) with sane +/// timeouts, so a slow, half-open, or hostile endpoint cannot hang the request +/// indefinitely — important because token introspection sits on the inbound +/// bearer-verification hot path. A timeout surfaces as a fail-closed error. +pub(crate) fn default_http_client() -> reqwest::Client { + reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .connect_timeout(std::time::Duration::from_secs(5)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()) +} + +/// The maximum response body (bytes) the security tier will buffer from an +/// OAuth2 endpoint before parsing — an RFC 7662 / RFC 6749 response is tiny, so +/// this caps a hostile endpoint's memory amplification while leaving ample room. +pub(crate) const MAX_OAUTH2_RESPONSE_BYTES: u64 = 1 << 20; // 1 MiB diff --git a/crates/security/src/oauth2/client.rs b/crates/security/src/oauth2/client.rs index 6cb7a9a..1f58466 100644 --- a/crates/security/src/oauth2/client.rs +++ b/crates/security/src/oauth2/client.rs @@ -26,7 +26,7 @@ use serde::{Deserialize, Serialize}; /// provider as a client application. Build one with /// [`ClientRegistration::new`] + the fluent setters, or use the /// [`google`], [`github`], and [`keycloak`] presets. -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] pub struct ClientRegistration { /// Unique id this registration is looked up by. @@ -58,6 +58,28 @@ pub struct ClientRegistration { /// Recommended for public clients (no client_secret); harmless and /// more secure for confidential clients too. pub use_pkce: bool, + /// The OIDC RP-initiated-logout `end_session_endpoint`; when set, logout + /// redirects the browser here to also end the session at the provider. + pub end_session_endpoint: String, + /// Where the provider should send the browser back after RP-initiated + /// logout (`post_logout_redirect_uri`). + pub post_logout_redirect_uri: String, +} + +// Manual `Debug` that redacts `client_secret` — the type is `Serialize` for +// config round-trips, so a derived `Debug` would print the secret in any log / +// span / panic that formats a registration. +impl std::fmt::Debug for ClientRegistration { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ClientRegistration") + .field("registration_id", &self.registration_id) + .field("client_id", &self.client_id) + .field("client_secret", &"") + .field("authorization_grant_type", &self.authorization_grant_type) + .field("provider_name", &self.provider_name) + .field("use_pkce", &self.use_pkce) + .finish_non_exhaustive() + } } impl ClientRegistration { @@ -138,6 +160,19 @@ impl ClientRegistration { self.use_pkce = enabled; self } + + /// Sets the OIDC `end_session_endpoint` for RP-initiated logout. + pub fn end_session_endpoint(mut self, uri: impl Into) -> Self { + self.end_session_endpoint = uri.into(); + self + } + + /// Sets the `post_logout_redirect_uri` the provider returns to after + /// RP-initiated logout. + pub fn post_logout_redirect_uri(mut self, uri: impl Into) -> Self { + self.post_logout_redirect_uri = uri.into(); + self + } } /// Creates a [`ClientRegistration`] pre-configured for Google OAuth2. @@ -185,6 +220,7 @@ pub fn keycloak( .user_info_uri(format!("{base}/protocol/openid-connect/userinfo")) .jwks_uri(format!("{base}/protocol/openid-connect/certs")) .issuer_uri(issuer_uri) + .end_session_endpoint(format!("{base}/protocol/openid-connect/logout")) .provider_name("Keycloak") } diff --git a/crates/security/src/oauth2/introspection.rs b/crates/security/src/oauth2/introspection.rs new file mode 100644 index 0000000..935029a --- /dev/null +++ b/crates/security/src/oauth2/introspection.rs @@ -0,0 +1,192 @@ +// 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. + +//! Opaque-token introspection (RFC 7662) — the Rust analog of Spring +//! Security's `OpaqueTokenIntrospector`. +//! +//! A resource server that receives **opaque** (non-JWT) bearer tokens cannot +//! validate them locally; it asks the authorization server's introspection +//! endpoint "is this token active, and who/what does it represent?". A +//! [`RemoteTokenIntrospector`] POSTs the token to the configured RFC 7662 +//! endpoint (authenticating as a client), and — when the response says +//! `active: true` — maps its claims to an [`Authentication`]. It implements +//! [`Verifier`](crate::Verifier), so it is a drop-in alternative to +//! [`JwksVerifier`](crate::JwksVerifier) behind a +//! [`BearerLayer`](crate::BearerLayer) for the opaque-token resource-server +//! pattern (the AS stays the source of truth; nothing is trusted locally). +//! +//! It fails **closed**: a transport error, a non-2xx response, a non-JSON +//! body, or `active: false`/absent all reject the token. + +use async_trait::async_trait; +use http::header; +use serde_json::Value; + +use crate::authentication::{Authentication, SecurityError, Verifier}; +use crate::jwks::claims_to_authentication; + +/// Introspects an opaque token — Spring's `OpaqueTokenIntrospector`. +#[async_trait] +pub trait TokenIntrospector: Send + Sync { + /// Resolves `token` to an [`Authentication`], or a [`SecurityError`] when it + /// is inactive, unknown, or unverifiable. + async fn introspect(&self, token: &str) -> Result; +} + +/// A [`TokenIntrospector`] backed by a remote RFC 7662 introspection endpoint — +/// Spring's `NimbusOpaqueTokenIntrospector`. +/// +/// Authenticates to the endpoint with HTTP Basic client credentials and POSTs +/// `token=` (form-encoded), per RFC 7662 §2.1. +pub struct RemoteTokenIntrospector { + introspection_uri: String, + client_id: String, + client_secret: String, + http: reqwest::Client, +} + +impl RemoteTokenIntrospector { + /// Builds an introspector for `introspection_uri`, authenticating as + /// `client_id` / `client_secret`. + #[must_use] + pub fn new( + introspection_uri: impl Into, + client_id: impl Into, + client_secret: impl Into, + ) -> Self { + Self { + introspection_uri: introspection_uri.into(), + client_id: client_id.into(), + client_secret: client_secret.into(), + http: crate::default_http_client(), + } + } +} + +impl std::fmt::Debug for RemoteTokenIntrospector { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RemoteTokenIntrospector") + .field("introspection_uri", &self.introspection_uri) + .field("client_id", &self.client_id) + .finish_non_exhaustive() + } +} + +#[async_trait] +impl TokenIntrospector for RemoteTokenIntrospector { + async fn introspect(&self, token: &str) -> Result { + let response = self + .http + .post(&self.introspection_uri) + .basic_auth(&self.client_id, Some(&self.client_secret)) + .form(&[("token", token), ("token_type_hint", "access_token")]) + .header(header::ACCEPT, "application/json") + .send() + .await + .map_err(|e| { + SecurityError::verification(format!("introspection request failed: {e}")) + })?; + if !response.status().is_success() { + return Err(SecurityError::verification(format!( + "introspection endpoint returned {}", + response.status() + ))); + } + // Reject an over-large body before buffering it — a hostile endpoint + // could otherwise force unbounded allocation on the verification path. + if response + .content_length() + .is_some_and(|len| len > crate::MAX_OAUTH2_RESPONSE_BYTES) + { + return Err(SecurityError::verification( + "introspection response too large", + )); + } + let body: Value = response.json().await.map_err(|e| { + SecurityError::verification(format!("introspection response not JSON: {e}")) + })?; + authentication_from_introspection(&body) + } +} + +/// A [`RemoteTokenIntrospector`] is a resource-server [`Verifier`]: dropping it +/// into a [`BearerLayer`](crate::BearerLayer) makes the layer validate opaque +/// tokens by introspection instead of local JWT verification. +#[async_trait] +impl Verifier for RemoteTokenIntrospector { + async fn verify(&self, token: &str) -> Result { + self.introspect(token).await + } +} + +/// Maps an RFC 7662 introspection response to an [`Authentication`], requiring +/// `active: true`. The remaining claims (`sub`, `scope`, `username`, …) are +/// mapped exactly as JWT claims are, so a principal authenticated by +/// introspection looks identical to one authenticated by JWT. +pub(crate) fn authentication_from_introspection( + response: &Value, +) -> Result { + let claims = response.as_object().ok_or_else(|| { + SecurityError::verification("introspection response is not a JSON object") + })?; + // RFC 7662 §2.2: `active` is REQUIRED; anything else means the token is not + // valid for use. + let active = claims + .get("active") + .and_then(Value::as_bool) + .unwrap_or(false); + if !active { + return Err(SecurityError::verification("token is inactive or unknown")); + } + let mut auth = claims_to_authentication(claims); + // RFC 7662 §2.2 names the resource owner in `username`; honor it over the + // OIDC `preferred_username`/`sub` fallback used for JWT claims. + if let Some(username) = claims.get("username").and_then(Value::as_str) { + auth.username = username.to_owned(); + } + Ok(auth) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn active_response_maps_to_authentication() { + let resp = json!({ + "active": true, + "sub": "user-7", + "username": "alice", + "scope": "read write", + "client_id": "svc" + }); + let auth = authentication_from_introspection(&resp).expect("active"); + assert_eq!(auth.principal, "user-7"); + assert_eq!(auth.username, "alice"); + // Space-separated scope becomes authorities. + assert!(auth.has_authority("read")); + assert!(auth.has_authority("write")); + } + + #[test] + fn inactive_or_missing_active_is_rejected() { + // active: false → reject. + assert!(authentication_from_introspection(&json!({"active": false, "sub": "x"})).is_err()); + // active absent → reject (fail closed). + assert!(authentication_from_introspection(&json!({"sub": "x"})).is_err()); + // not an object → reject. + assert!(authentication_from_introspection(&json!("nope")).is_err()); + } +} diff --git a/crates/security/src/oauth2/login.rs b/crates/security/src/oauth2/login.rs index 0b4c12f..4ddd879 100644 --- a/crates/security/src/oauth2/login.rs +++ b/crates/security/src/oauth2/login.rs @@ -29,7 +29,10 @@ //! audience + nonce), otherwise fetches userinfo, then stores the //! resulting [`Authentication`] in the session (rotating the session //! id against fixation). -//! - `POST /logout` — invalidates the session and redirects to `/`. +//! - `POST /logout` — invalidates the session and, when the login provider +//! advertises an OIDC `end_session_endpoint`, redirects the browser there for +//! RP-initiated logout (`id_token_hint` + `post_logout_redirect_uri`); +//! otherwise redirects to `/`. //! //! Session state goes through the local [`LoginSession`] / //! [`LoginSessionStore`] traits so `firefly-session` (or any cookie @@ -68,6 +71,12 @@ pub const SESSION_KEY_PKCE_VERIFIER: &str = "oauth2_pkce_verifier"; pub const SESSION_KEY_SECURITY_CONTEXT: &str = "SECURITY_CONTEXT"; /// Session key holding the post-login redirect target. pub const SESSION_KEY_REDIRECT_URI: &str = "oauth2_redirect_uri"; +/// Session key holding the raw OIDC `id_token` from login, used as the +/// `id_token_hint` for RP-initiated logout. +pub const SESSION_KEY_ID_TOKEN: &str = "oauth2_id_token"; +/// Session key holding the `registration_id` the user logged in with, so +/// logout can resolve the provider's `end_session_endpoint`. +pub const SESSION_KEY_REGISTRATION_ID: &str = "oauth2_registration_id"; /// Per-browser session state — the slice of pyfly's `HttpSession` the /// login flow needs. Values are strings; structured data (the security @@ -340,13 +349,17 @@ fn error_json(status: StatusCode, error: &str, message: &str) -> Response { } /// Renders a 302 redirect (pyfly uses 302, not axum's 303/307 -/// helpers). +/// helpers). The `Location` value is built fallibly: a control char (CR/LF, …) +/// in a misconfigured `end_session_endpoint` would make `HeaderValue` parsing +/// fail, so fall back to `"/"` rather than panicking the request task. fn redirect(location: &str) -> Response { + let value = header::HeaderValue::from_str(location) + .unwrap_or_else(|_| header::HeaderValue::from_static("/")); Response::builder() .status(StatusCode::FOUND) - .header(header::LOCATION, location) + .header(header::LOCATION, value) .body(Body::empty()) - .expect("static redirect must build") + .expect("redirect response with a valid header value must build") } /// `GET /oauth2/authorization/:registration_id` — redirect the user to @@ -557,6 +570,17 @@ async fn handle_callback( .set_attribute(SESSION_KEY_SECURITY_CONTEXT, serialized) .await; + // Remember the registration and id_token so RP-initiated logout can hint + // the provider's end_session_endpoint (OIDC). + session + .set_attribute(SESSION_KEY_REGISTRATION_ID, registration_id.clone()) + .await; + if let Some(id_token) = token_response.get("id_token").and_then(Value::as_str) { + session + .set_attribute(SESSION_KEY_ID_TOKEN, id_token.to_owned()) + .await; + } + let redirect_uri = session .get_attribute(SESSION_KEY_REDIRECT_URI) .await @@ -565,7 +589,10 @@ async fn handle_callback( redirect(&redirect_uri) } -/// `POST /logout` — invalidate the session and redirect to the root. +/// `POST /logout` — invalidate the local session and, when the login provider +/// advertises an OIDC `end_session_endpoint`, redirect the browser there to end +/// the session at the provider too (RP-initiated logout). Otherwise redirect to +/// the root. async fn handle_logout(State(state): State>, headers: HeaderMap) -> Response { let session = state.sessions.session(&headers).await; @@ -580,8 +607,53 @@ async fn handle_logout(State(state): State>, headers: HeaderMap) } } + // Resolve the RP-initiated logout URL (if the login provider supports it) + // BEFORE invalidating, since invalidation clears the session attributes. + let registration_id = session.get_attribute(SESSION_KEY_REGISTRATION_ID).await; + let id_token = session.get_attribute(SESSION_KEY_ID_TOKEN).await; + let logout_url = match registration_id.and_then(|id| state.clients.find_by_registration_id(&id)) + { + Some(registration) => oidc_logout_url(®istration, id_token.as_deref(), None), + None => None, + }; + session.invalidate().await; - redirect("/") + redirect(&logout_url.unwrap_or_else(|| "/".to_string())) +} + +/// Builds an OIDC RP-initiated-logout URL for `registration`, or `None` when the +/// provider advertises no `end_session_endpoint`. Appends `id_token_hint`, +/// `post_logout_redirect_uri` (the explicit argument, else the registration +/// default), and `client_id` — the Rust analog of Spring's +/// `OidcClientInitiatedLogoutSuccessHandler`. +pub fn oidc_logout_url( + registration: &ClientRegistration, + id_token_hint: Option<&str>, + post_logout_redirect_uri: Option<&str>, +) -> Option { + if registration.end_session_endpoint.is_empty() { + return None; + } + let mut params: Vec<(&str, &str)> = Vec::new(); + if let Some(hint) = id_token_hint { + params.push(("id_token_hint", hint)); + } + let post_logout = post_logout_redirect_uri.unwrap_or(®istration.post_logout_redirect_uri); + if !post_logout.is_empty() { + params.push(("post_logout_redirect_uri", post_logout)); + } + params.push(("client_id", ®istration.client_id)); + let separator = if registration.end_session_endpoint.contains('?') { + '&' + } else { + '?' + }; + Some(format!( + "{}{}{}", + registration.end_session_endpoint, + separator, + urlencode_pairs(¶ms) + )) } /// Reads the principal from a session's stored `SECURITY_CONTEXT` attribute @@ -762,6 +834,32 @@ mod tests { assert!(auth.principal.is_empty()); } + #[test] + fn oidc_logout_url_builds_end_session_request() { + use super::super::client::ClientRegistration; + + let reg = ClientRegistration::new("kc", "cid") + .end_session_endpoint("https://idp/logout") + .post_logout_redirect_uri("https://app/bye"); + let url = oidc_logout_url(®, Some("id-tok"), None).expect("end_session set"); + assert_eq!( + url, + "https://idp/logout?id_token_hint=id-tok\ + &post_logout_redirect_uri=https%3A%2F%2Fapp%2Fbye&client_id=cid" + ); + + // An explicit post-logout URI overrides the registration default. + let url2 = oidc_logout_url(®, None, Some("https://other/done")).unwrap(); + assert_eq!( + url2, + "https://idp/logout?post_logout_redirect_uri=https%3A%2F%2Fother%2Fdone&client_id=cid" + ); + + // No end_session_endpoint → no RP-initiated logout. + let plain = ClientRegistration::new("x", "cid"); + assert!(oidc_logout_url(&plain, Some("id-tok"), None).is_none()); + } + #[tokio::test] async fn in_memory_session_rotates_and_invalidates() { let session = InMemoryLoginSession::new(); diff --git a/crates/security/src/oauth2/mod.rs b/crates/security/src/oauth2/mod.rs index 8240e74..00ef17d 100644 --- a/crates/security/src/oauth2/mod.rs +++ b/crates/security/src/oauth2/mod.rs @@ -31,7 +31,10 @@ mod authorization_server; mod client; +mod introspection; mod login; +mod outbound; +mod server_endpoints; mod token_store; pub use authorization_server::{AuthorizationServer, OAuth2Error, TokenRequest, TokenResponse}; @@ -39,11 +42,18 @@ pub use client::{ github, google, keycloak, ClientRegistration, ClientRegistrationRepository, InMemoryClientRegistrationRepository, }; +pub use introspection::{RemoteTokenIntrospector, TokenIntrospector}; pub use login::{ - generate_pkce, pkce_challenge, FixedLoginSessionStore, InMemoryLoginSession, LoginSession, - LoginSessionStore, OAuth2LoginHandler, SESSION_KEY_NONCE, SESSION_KEY_PKCE_VERIFIER, - SESSION_KEY_REDIRECT_URI, SESSION_KEY_SECURITY_CONTEXT, SESSION_KEY_STATE, + generate_pkce, oidc_logout_url, pkce_challenge, FixedLoginSessionStore, InMemoryLoginSession, + LoginSession, LoginSessionStore, OAuth2LoginHandler, SESSION_KEY_ID_TOKEN, SESSION_KEY_NONCE, + SESSION_KEY_PKCE_VERIFIER, SESSION_KEY_REDIRECT_URI, SESSION_KEY_REGISTRATION_ID, + SESSION_KEY_SECURITY_CONTEXT, SESSION_KEY_STATE, }; +pub use outbound::{ + InMemoryOAuth2AuthorizedClientService, OAuth2AuthorizedClient, OAuth2AuthorizedClientManager, + OAuth2AuthorizedClientService, DEFAULT_CLOCK_SKEW_SECONDS, +}; +pub use server_endpoints::AuthorizationServerRouter; pub use token_store::{ validate_table_name, InMemoryTokenStore, PostgresTokenStore, RedisTokenStore, TokenStore, POSTGRES_TOKEN_TABLE, REDIS_TOKEN_KEY_PREFIX, diff --git a/crates/security/src/oauth2/outbound.rs b/crates/security/src/oauth2/outbound.rs new file mode 100644 index 0000000..28f419b --- /dev/null +++ b/crates/security/src/oauth2/outbound.rs @@ -0,0 +1,464 @@ +// 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. + +//! Outbound OAuth2 client — the Rust analog of Spring Security's +//! `OAuth2AuthorizedClientManager` / `OAuth2AuthorizedClientService`. +//! +//! Where [`OAuth2LoginHandler`](super::OAuth2LoginHandler) handles the *inbound* +//! browser login, this is the *outbound* side: how the application obtains, +//! **caches**, and **refreshes** the access tokens it needs to call +//! downstream OAuth2-protected services. +//! +//! * [`OAuth2AuthorizedClient`] — a held token (access + optional refresh + +//! expiry) for a `(registration, principal)` pair (Spring's +//! `OAuth2AuthorizedClient`). +//! * [`OAuth2AuthorizedClientService`] — where those are stored +//! ([`InMemoryOAuth2AuthorizedClientService`] by default). +//! * [`OAuth2AuthorizedClientManager`] — obtains a client via the +//! **client-credentials** grant (service-to-service) or refreshes an existing +//! one via the **refresh-token** grant, reusing a cached token until it is +//! within the clock-skew window of expiry. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use async_trait::async_trait; +use http::header; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tokio::sync::Mutex; + +use super::authorization_server::OAuth2Error; +use super::client::{ClientRegistration, ClientRegistrationRepository}; + +/// Default leeway treating a token as expired slightly early, so a downstream +/// call never races the actual expiry — mirrors Spring's 60s default. +pub const DEFAULT_CLOCK_SKEW_SECONDS: u64 = 60; + +/// Conservative lifetime assumed when a token response omits `expires_in` +/// (RFC 6749 §5.1 makes it optional, and "absent" means *unknown*, not +/// *infinite*). Bounding it forces a re-fetch rather than caching the token +/// forever. +pub const DEFAULT_FALLBACK_TTL_SECONDS: u64 = 300; + +/// A token the application holds to call a downstream OAuth2-protected service +/// — Spring's `OAuth2AuthorizedClient`. Its `Debug` redacts the tokens; the +/// `Serialize` form (for a persistent [`OAuth2AuthorizedClientService`]) carries +/// them in clear, so persist only to a secured store. +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct OAuth2AuthorizedClient { + /// The client registration this token was obtained for. + pub registration_id: String, + /// The principal the token represents — the client id for a + /// client-credentials token, or a user name for a delegated one. + pub principal_name: String, + /// The bearer access token to send downstream. + pub access_token: String, + /// The refresh token, when the grant returned one. + pub refresh_token: Option, + /// Absolute expiry in epoch seconds, when the grant returned `expires_in`. + pub expires_at: Option, + /// The granted scopes. + pub scopes: Vec, +} + +// Manual `Debug` that redacts the access/refresh tokens (live bearer +// credentials), so logging/error context can never print them in clear. +impl std::fmt::Debug for OAuth2AuthorizedClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OAuth2AuthorizedClient") + .field("registration_id", &self.registration_id) + .field("principal_name", &self.principal_name) + .field("access_token", &"") + .field( + "refresh_token", + &self.refresh_token.as_ref().map(|_| ""), + ) + .field("expires_at", &self.expires_at) + .field("scopes", &self.scopes) + .finish() + } +} + +impl OAuth2AuthorizedClient { + /// Whether the access token is at or within `skew_seconds` of expiry. A + /// token with no expiry information is treated as non-expiring. + #[must_use] + pub fn is_expired(&self, skew_seconds: u64) -> bool { + match self.expires_at { + Some(exp) => now_secs().saturating_add(skew_seconds) >= exp, + None => false, + } + } +} + +/// Stores [`OAuth2AuthorizedClient`]s by `(registration_id, principal_name)` — +/// Spring's `OAuth2AuthorizedClientService`. +#[async_trait] +pub trait OAuth2AuthorizedClientService: Send + Sync { + /// Saves (inserts or replaces) an authorized client. + async fn save(&self, client: OAuth2AuthorizedClient); + /// Loads the authorized client for `(registration_id, principal_name)`. + async fn load( + &self, + registration_id: &str, + principal_name: &str, + ) -> Option; + /// Removes the authorized client for `(registration_id, principal_name)`. + async fn remove(&self, registration_id: &str, principal_name: &str); +} + +/// In-memory [`OAuth2AuthorizedClientService`] (default; single-process). +#[derive(Default)] +pub struct InMemoryOAuth2AuthorizedClientService { + clients: Mutex>, +} + +impl InMemoryOAuth2AuthorizedClientService { + /// Builds an empty store. + #[must_use] + pub fn new() -> Self { + Self::default() + } +} + +#[async_trait] +impl OAuth2AuthorizedClientService for InMemoryOAuth2AuthorizedClientService { + async fn save(&self, client: OAuth2AuthorizedClient) { + let key = ( + client.registration_id.clone(), + client.principal_name.clone(), + ); + self.clients.lock().await.insert(key, client); + } + async fn load( + &self, + registration_id: &str, + principal_name: &str, + ) -> Option { + self.clients + .lock() + .await + .get(&(registration_id.to_owned(), principal_name.to_owned())) + .cloned() + } + async fn remove(&self, registration_id: &str, principal_name: &str) { + self.clients + .lock() + .await + .remove(&(registration_id.to_owned(), principal_name.to_owned())); + } +} + +/// Obtains, caches, and refreshes outbound access tokens — Spring's +/// `OAuth2AuthorizedClientManager`. +pub struct OAuth2AuthorizedClientManager { + clients: Arc, + service: Arc, + http: reqwest::Client, + clock_skew_seconds: u64, +} + +impl OAuth2AuthorizedClientManager { + /// Builds the manager over a registration repository and an authorized-client + /// store, with the default clock skew. + #[must_use] + pub fn new( + clients: Arc, + service: Arc, + ) -> Self { + Self { + clients, + service, + http: crate::default_http_client(), + clock_skew_seconds: DEFAULT_CLOCK_SKEW_SECONDS, + } + } + + /// Overrides the expiry clock-skew leeway (seconds). + #[must_use] + pub fn clock_skew_seconds(mut self, seconds: u64) -> Self { + self.clock_skew_seconds = seconds; + self + } + + /// Obtains a service token for `registration_id` via the **client-credentials** + /// grant. Returns a cached token while it is still valid; when it has expired + /// it is refreshed (if a refresh token is held) or re-fetched. The principal + /// for a client-credentials token is the client id. + pub async fn authorize_client_credentials( + &self, + registration_id: &str, + ) -> Result { + let registration = self.registration(registration_id)?; + let principal = registration.client_id.clone(); + + if let Some(existing) = self.service.load(registration_id, &principal).await { + if !existing.is_expired(self.clock_skew_seconds) { + return Ok(existing); + } + // Expired: prefer a refresh, fall back to a fresh grant. + if existing.refresh_token.is_some() { + if let Ok(refreshed) = self.refresh_grant(®istration, &existing).await { + return Ok(refreshed); + } + } + } + + let scope = registration.scopes.join(" "); + let mut form = vec![("grant_type", "client_credentials")]; + if !scope.is_empty() { + form.push(("scope", scope.as_str())); + } + let response = self.request_token(®istration, &form).await?; + let client = authorized_client_from_token_response( + registration_id, + &principal, + ®istration.scopes, + None, + &response, + now_secs(), + )?; + self.service.save(client.clone()).await; + Ok(client) + } + + /// Refreshes the token held for `(registration_id, principal_name)` via the + /// **refresh-token** grant. Errors when no client (or no refresh token) is + /// stored. + pub async fn refresh( + &self, + registration_id: &str, + principal_name: &str, + ) -> Result { + let registration = self.registration(registration_id)?; + let existing = self + .service + .load(registration_id, principal_name) + .await + .ok_or_else(|| { + OAuth2Error::new("no_authorized_client", "no stored client to refresh") + })?; + self.refresh_grant(®istration, &existing).await + } + + /// Performs the refresh-token grant for `existing` and stores the result. + async fn refresh_grant( + &self, + registration: &ClientRegistration, + existing: &OAuth2AuthorizedClient, + ) -> Result { + let refresh_token = existing.refresh_token.as_deref().ok_or_else(|| { + OAuth2Error::new("no_refresh_token", "stored client has no refresh token") + })?; + let form = vec![ + ("grant_type", "refresh_token"), + ("refresh_token", refresh_token), + ]; + let response = self.request_token(registration, &form).await?; + let client = authorized_client_from_token_response( + &existing.registration_id, + &existing.principal_name, + &existing.scopes, + // RFC 6749 §6: a refresh response may omit a new refresh token — keep + // the current one. + existing.refresh_token.as_deref(), + &response, + now_secs(), + )?; + self.service.save(client.clone()).await; + Ok(client) + } + + fn registration(&self, registration_id: &str) -> Result { + self.clients + .find_by_registration_id(registration_id) + .ok_or_else(|| { + OAuth2Error::new( + "unknown_client_registration", + format!("no client registration `{registration_id}`"), + ) + }) + } + + /// POSTs a token request (client_secret_basic auth) and returns the parsed + /// JSON body, mapping transport / non-2xx / non-JSON failures to errors. + async fn request_token( + &self, + registration: &ClientRegistration, + form: &[(&str, &str)], + ) -> Result { + let response = self + .http + .post(®istration.token_uri) + .basic_auth(®istration.client_id, Some(®istration.client_secret)) + .form(form) + .header(header::ACCEPT, "application/json") + .send() + .await + .map_err(|e| OAuth2Error::new("token_request_failed", e.to_string()))?; + if !response.status().is_success() { + return Err(OAuth2Error::new( + "token_endpoint_error", + format!("token endpoint returned {}", response.status()), + )); + } + if response + .content_length() + .is_some_and(|len| len > crate::MAX_OAUTH2_RESPONSE_BYTES) + { + return Err(OAuth2Error::new( + "invalid_token_response", + "token response too large", + )); + } + response + .json() + .await + .map_err(|e| OAuth2Error::new("invalid_token_response", e.to_string())) + } +} + +/// Builds an [`OAuth2AuthorizedClient`] from a token-endpoint JSON response. +/// `previous_refresh` is retained when the response omits a refresh token, and +/// `scope_fallback` is used when it omits `scope`. +pub(crate) fn authorized_client_from_token_response( + registration_id: &str, + principal_name: &str, + scope_fallback: &[String], + previous_refresh: Option<&str>, + response: &Value, + now: u64, +) -> Result { + let obj = response + .as_object() + .ok_or_else(|| OAuth2Error::new("invalid_token_response", "not a JSON object"))?; + let access_token = obj + .get("access_token") + .and_then(Value::as_str) + .ok_or_else(|| OAuth2Error::new("invalid_token_response", "missing access_token"))? + .to_owned(); + let refresh_token = obj + .get("refresh_token") + .and_then(Value::as_str) + .map(str::to_owned) + .or_else(|| previous_refresh.map(str::to_owned)); + // RFC 6749 §5.1: `expires_in` is optional, and its absence means the + // lifetime is unknown — assume a short, bounded one rather than caching the + // token forever (a missing/None expiry would otherwise read as non-expiring). + let expires_at = Some( + now.saturating_add( + obj.get("expires_in") + .and_then(Value::as_u64) + .unwrap_or(DEFAULT_FALLBACK_TTL_SECONDS), + ), + ); + let scopes = obj + .get("scope") + .and_then(Value::as_str) + .map(|s| s.split_whitespace().map(str::to_owned).collect::>()) + .unwrap_or_else(|| scope_fallback.to_vec()); + Ok(OAuth2AuthorizedClient { + registration_id: registration_id.to_owned(), + principal_name: principal_name.to_owned(), + access_token, + refresh_token, + expires_at, + scopes, + }) +} + +/// The current wall-clock time in epoch seconds (0 on a pre-epoch clock). +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 serde_json::json; + + #[test] + fn parses_token_response_with_expiry_and_scope() { + let resp = json!({ + "access_token": "at-1", + "refresh_token": "rt-1", + "expires_in": 3600, + "scope": "read write" + }); + let client = + authorized_client_from_token_response("reg", "svc", &[], None, &resp, 1000).unwrap(); + assert_eq!(client.access_token, "at-1"); + assert_eq!(client.refresh_token.as_deref(), Some("rt-1")); + assert_eq!(client.expires_at, Some(4600)); + assert_eq!(client.scopes, vec!["read", "write"]); + // 4600 expiry, now ~1000 → not expired; with huge skew → expired. + assert!(!client.is_expired(0) || client.expires_at.unwrap() <= now_secs()); + } + + #[test] + fn refresh_response_retains_previous_refresh_token_and_scope() { + // A refresh response omits refresh_token, scope, AND expires_in: keep the + // old refresh token + scope, and assume the bounded fallback lifetime + // (never an immortal token). + let resp = json!({ "access_token": "at-2" }); + let scopes = vec!["api".to_string()]; + let client = authorized_client_from_token_response( + "reg", + "svc", + &scopes, + Some("rt-old"), + &resp, + 1000, + ) + .unwrap(); + assert_eq!(client.access_token, "at-2"); + assert_eq!(client.refresh_token.as_deref(), Some("rt-old")); + assert_eq!(client.scopes, vec!["api"]); + // Missing expires_in → bounded fallback expiry, not non-expiring. + assert_eq!(client.expires_at, Some(1000 + DEFAULT_FALLBACK_TTL_SECONDS)); + } + + #[test] + fn missing_access_token_is_an_error() { + let resp = json!({ "token_type": "Bearer", "expires_in": 60 }); + assert!(authorized_client_from_token_response("reg", "svc", &[], None, &resp, 0).is_err()); + assert!( + authorized_client_from_token_response("reg", "svc", &[], None, &json!("x"), 0).is_err() + ); + } + + #[test] + fn is_expired_honors_skew_and_missing_expiry() { + let past = OAuth2AuthorizedClient { + registration_id: "r".into(), + principal_name: "p".into(), + access_token: "a".into(), + refresh_token: None, + expires_at: Some(1), // long past + scopes: vec![], + }; + assert!(past.is_expired(0)); + let none = OAuth2AuthorizedClient { + expires_at: None, + ..past.clone() + }; + // No expiry info → treated as non-expiring. + assert!(!none.is_expired(0)); + } +} diff --git a/crates/security/src/oauth2/server_endpoints.rs b/crates/security/src/oauth2/server_endpoints.rs new file mode 100644 index 0000000..6cbfe40 --- /dev/null +++ b/crates/security/src/oauth2/server_endpoints.rs @@ -0,0 +1,219 @@ +// 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. + +//! HTTP endpoints for the [`AuthorizationServer`] — turns the callable token +//! logic into a mountable OAuth2 surface, plus RFC 8414 discovery. +//! +//! [`AuthorizationServerRouter::router`] mounts: +//! +//! * `POST /oauth2/token` — the RFC 6749 token endpoint (the `client_credentials` +//! and `refresh_token` grants the [`AuthorizationServer`] supports), with +//! client authentication via `client_secret_post` (form fields). Success is a +//! `200` [`TokenResponse`]; a failure is the RFC 6749 §5.2 error envelope +//! (`{"error", "error_description"}`, `401` for `invalid_client`, else `400`). +//! * `GET /.well-known/oauth-authorization-server` — the RFC 8414 Authorization +//! Server Metadata document (issuer, token endpoint, supported grants and +//! client-auth methods). No `jwks_uri` is advertised: tokens are HS256-signed, +//! so there is no public verification key to publish. + +use std::sync::Arc; + +use axum::extract::{Form, State}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use http::StatusCode; +use serde_json::{json, Value}; + +use super::authorization_server::{AuthorizationServer, TokenRequest}; + +/// Shared state behind the authorization-server endpoints. +struct EndpointsState { + server: Arc, + issuer: String, +} + +/// Mounts the [`AuthorizationServer`] as HTTP endpoints — the token endpoint and +/// RFC 8414 metadata. +pub struct AuthorizationServerRouter { + state: Arc, +} + +impl AuthorizationServerRouter { + /// Builds the router over `server`, advertising `issuer` (the server's public + /// base URL, e.g. `https://auth.example.com`) in the metadata + endpoint URLs. + #[must_use] + pub fn new(server: Arc, issuer: impl Into) -> Self { + Self { + state: Arc::new(EndpointsState { + server, + issuer: issuer.into(), + }), + } + } + + /// The axum [`Router`] mounting `POST /oauth2/token` and + /// `GET /.well-known/oauth-authorization-server`. + pub fn router(&self) -> Router { + Router::new() + .route("/oauth2/token", post(token_endpoint)) + .route( + "/.well-known/oauth-authorization-server", + get(metadata_endpoint), + ) + .with_state(Arc::clone(&self.state)) + } +} + +/// `POST /oauth2/token` — RFC 6749 token endpoint. +async fn token_endpoint( + State(state): State>, + Form(request): Form, +) -> Response { + match state.server.token(&request).await { + Ok(token) => (StatusCode::OK, Json(token)).into_response(), + Err(error) => { + if error.code.eq_ignore_ascii_case("server_error") { + // An internal failure (e.g. token signing) is a 5xx; never echo + // the raw internal error detail to the caller. + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ + "error": "server_error", + "error_description": "internal error", + })), + ) + .into_response(); + } + // RFC 6749 §5.2: `invalid_client` is a `401`; other token errors a + // `400`. Error codes are lowercased to the registered RFC names. + let status = if error.code.eq_ignore_ascii_case("invalid_client") { + StatusCode::UNAUTHORIZED + } else { + StatusCode::BAD_REQUEST + }; + let body = json!({ + "error": error.code.to_lowercase(), + "error_description": error.message, + }); + (status, Json(body)).into_response() + } + } +} + +/// `GET /.well-known/oauth-authorization-server` — RFC 8414 metadata. +async fn metadata_endpoint(State(state): State>) -> Json { + let issuer = state.issuer.trim_end_matches('/'); + Json(json!({ + "issuer": issuer, + "token_endpoint": format!("{issuer}/oauth2/token"), + "grant_types_supported": ["client_credentials", "refresh_token"], + "token_endpoint_auth_methods_supported": ["client_secret_post"], + // No authorization_code/PKCE server flow yet, so no authorization or + // response types are advertised. + "response_types_supported": [], + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::oauth2::{ + ClientRegistration, InMemoryClientRegistrationRepository, InMemoryTokenStore, + }; + use http::Request; + use http_body_util::BodyExt; + use tower::ServiceExt; + + fn router() -> Router { + let repo = + InMemoryClientRegistrationRepository::new([ClientRegistration::new("m2m", "m2m") + .client_secret("s3cret") + .authorization_grant_type("client_credentials") + .scopes(&["api"])]); + let server = Arc::new(AuthorizationServer::new( + "signing-secret", + Arc::new(repo), + Arc::new(InMemoryTokenStore::new()), + )); + AuthorizationServerRouter::new(server, "https://auth.example.com/").router() + } + + async fn body_json(resp: Response) -> Value { + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&bytes).unwrap() + } + + fn form_post(uri: &str, body: &str) -> Request { + Request::builder() + .method(http::Method::POST) + .uri(uri) + .header( + http::header::CONTENT_TYPE, + "application/x-www-form-urlencoded", + ) + .body(axum::body::Body::from(body.to_owned())) + .unwrap() + } + + #[tokio::test] + async fn token_endpoint_issues_for_client_credentials() { + let resp = router() + .oneshot(form_post( + "/oauth2/token", + "grant_type=client_credentials&client_id=m2m&client_secret=s3cret", + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = body_json(resp).await; + assert!(body.get("access_token").and_then(Value::as_str).is_some()); + assert_eq!(body["token_type"], "Bearer"); + assert_eq!(body["scope"], "api"); + } + + #[tokio::test] + async fn token_endpoint_rejects_bad_client_with_401() { + let resp = router() + .oneshot(form_post( + "/oauth2/token", + "grant_type=client_credentials&client_id=m2m&client_secret=wrong", + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + assert_eq!(body_json(resp).await["error"], "invalid_client"); + } + + #[tokio::test] + async fn metadata_document_advertises_issuer_and_token_endpoint() { + let resp = router() + .oneshot( + Request::builder() + .uri("/.well-known/oauth-authorization-server") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = body_json(resp).await; + assert_eq!(body["issuer"], "https://auth.example.com"); + assert_eq!( + body["token_endpoint"], + "https://auth.example.com/oauth2/token" + ); + assert_eq!(body["grant_types_supported"][0], "client_credentials"); + } +} diff --git a/crates/security/tests/oauth2_introspection_test.rs b/crates/security/tests/oauth2_introspection_test.rs new file mode 100644 index 0000000..7538337 --- /dev/null +++ b/crates/security/tests/oauth2_introspection_test.rs @@ -0,0 +1,75 @@ +// 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. + +//! End-to-end test for RFC 7662 opaque-token introspection +//! ([`RemoteTokenIntrospector`]) against an in-process axum introspection +//! endpoint — covering the real HTTP round-trip and the `Verifier` drop-in. + +use std::collections::HashMap; + +use axum::routing::post; +use axum::{Form, Json, Router}; +use firefly_security::oauth2::{RemoteTokenIntrospector, TokenIntrospector}; +use firefly_security::Verifier; +use serde_json::{json, Value}; + +/// A minimal RFC 7662 endpoint: `good-token` is active, everything else isn't. +async fn introspect_handler(Form(form): Form>) -> Json { + if form.get("token").map(String::as_str) == Some("good-token") { + Json(json!({ + "active": true, + "sub": "u1", + "username": "alice", + "scope": "read write", + "client_id": "svc" + })) + } else { + Json(json!({ "active": false })) + } +} + +/// Spawns the mock introspection server and returns its `/introspect` URL. +async fn spawn_introspection_server() -> String { + let app = Router::new().route("/introspect", post(introspect_handler)); + 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}/introspect") +} + +#[tokio::test] +async fn introspects_active_token_and_rejects_inactive() { + let uri = spawn_introspection_server().await; + let introspector = RemoteTokenIntrospector::new(uri, "rs-client", "rs-secret"); + + // An active token resolves to the authenticated principal + authorities. + let auth = introspector + .introspect("good-token") + .await + .expect("active token"); + assert_eq!(auth.principal, "u1"); + assert_eq!(auth.username, "alice"); + assert!(auth.has_authority("read")); + assert!(auth.has_authority("write")); + + // The same introspector is a drop-in resource-server Verifier. + let via_verifier = introspector.verify("good-token").await.expect("verify"); + assert_eq!(via_verifier.principal, "u1"); + + // An inactive / unknown token fails closed, both ways. + assert!(introspector.introspect("revoked").await.is_err()); + assert!(introspector.verify("revoked").await.is_err()); +} diff --git a/crates/security/tests/oauth2_login_test.rs b/crates/security/tests/oauth2_login_test.rs index 7ff6358..b21c51d 100644 --- a/crates/security/tests/oauth2_login_test.rs +++ b/crates/security/tests/oauth2_login_test.rs @@ -27,8 +27,9 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine; use firefly_security::oauth2::{ pkce_challenge, ClientRegistration, FixedLoginSessionStore, - InMemoryClientRegistrationRepository, LoginSession, OAuth2LoginHandler, SESSION_KEY_NONCE, - SESSION_KEY_PKCE_VERIFIER, SESSION_KEY_SECURITY_CONTEXT, SESSION_KEY_STATE, + InMemoryClientRegistrationRepository, LoginSession, OAuth2LoginHandler, SESSION_KEY_ID_TOKEN, + SESSION_KEY_NONCE, SESSION_KEY_PKCE_VERIFIER, SESSION_KEY_REGISTRATION_ID, + SESSION_KEY_SECURITY_CONTEXT, SESSION_KEY_STATE, }; use http::{header, Method, StatusCode}; use http_body_util::BodyExt; @@ -630,3 +631,45 @@ async fn logout_invalidates_session_and_redirects() { None ); } + +#[tokio::test] +async fn logout_redirects_to_oidc_end_session_endpoint() { + // A provider that advertises RP-initiated logout. + let reg = registration("http://unused", false).end_session_endpoint("https://idp/logout"); + let (app, sessions) = login_app(reg); + let session = sessions.session(); + session + .set_attribute(SESSION_KEY_SECURITY_CONTEXT, "{}".into()) + .await; + session + .set_attribute(SESSION_KEY_REGISTRATION_ID, "acme".into()) + .await; + session + .set_attribute(SESSION_KEY_ID_TOKEN, "the-id-token".into()) + .await; + + let req = Request::builder() + .method(Method::POST) + .uri("/logout") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + + // Redirected to the provider's end_session_endpoint with the id_token hint. + assert_eq!(resp.status(), StatusCode::FOUND); + let location = resp.headers()[header::LOCATION].to_str().unwrap(); + assert!( + location.starts_with("https://idp/logout?"), + "location: {location}" + ); + assert!( + location.contains("id_token_hint=the-id-token"), + "{location}" + ); + assert!(location.contains("client_id=cid"), "{location}"); + // Local session still invalidated. + assert_eq!( + session.get_attribute(SESSION_KEY_SECURITY_CONTEXT).await, + None + ); +} diff --git a/crates/security/tests/oauth2_outbound_test.rs b/crates/security/tests/oauth2_outbound_test.rs new file mode 100644 index 0000000..3f63bbe --- /dev/null +++ b/crates/security/tests/oauth2_outbound_test.rs @@ -0,0 +1,137 @@ +// 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. + +//! End-to-end test for the outbound OAuth2 client +//! ([`OAuth2AuthorizedClientManager`]) against an in-process token endpoint: +//! the client-credentials grant obtains + caches a token, and an expired token +//! holding a refresh token is refreshed. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use axum::extract::State; +use axum::routing::post; +use axum::{Form, Json, Router}; +use firefly_security::oauth2::{ + ClientRegistration, InMemoryClientRegistrationRepository, + InMemoryOAuth2AuthorizedClientService, OAuth2AuthorizedClient, OAuth2AuthorizedClientManager, + OAuth2AuthorizedClientService, +}; +use serde_json::{json, Value}; + +/// A token endpoint: client-credentials issues a unique `cc-N` token (so a +/// cache hit is observable as an unchanged N); refresh issues `refreshed-1`. +async fn token_handler( + State(counter): State>>, + Form(form): Form>, +) -> Json { + match form.get("grant_type").map(String::as_str) { + Some("client_credentials") => { + let mut n = counter.lock().unwrap(); + *n += 1; + Json(json!({ + "access_token": format!("cc-{n}"), + "token_type": "Bearer", + "expires_in": 3600, + "scope": "api" + })) + } + Some("refresh_token") => Json(json!({ + "access_token": "refreshed-1", + "token_type": "Bearer", + "expires_in": 3600 + })), + _ => Json(json!({ "error": "unsupported_grant_type" })), + } +} + +async fn spawn_token_server() -> (String, Arc>) { + let counter = Arc::new(Mutex::new(0u32)); + let app = Router::new() + .route("/token", post(token_handler)) + .with_state(counter.clone()); + 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}/token"), counter) +} + +fn registration(token_uri: &str) -> ClientRegistration { + ClientRegistration::new("svc-reg", "svc-client") + .client_secret("svc-secret") + .scopes(&["api"]) + .token_uri(token_uri) +} + +#[tokio::test] +async fn client_credentials_obtains_and_then_caches() { + let (token_uri, counter) = spawn_token_server().await; + let repo = Arc::new(InMemoryClientRegistrationRepository::new([registration( + &token_uri, + )])); + let service = Arc::new(InMemoryOAuth2AuthorizedClientService::new()); + let manager = OAuth2AuthorizedClientManager::new(repo, service); + + // First call performs the grant. + let first = manager + .authorize_client_credentials("svc-reg") + .await + .expect("grant"); + assert_eq!(first.access_token, "cc-1"); + assert_eq!(first.scopes, vec!["api"]); + + // Second call returns the cached, still-valid token — no new grant. + let second = manager + .authorize_client_credentials("svc-reg") + .await + .expect("cached"); + assert_eq!(second.access_token, "cc-1"); + assert_eq!( + *counter.lock().unwrap(), + 1, + "token endpoint hit exactly once" + ); +} + +#[tokio::test] +async fn expired_token_with_refresh_token_is_refreshed() { + let (token_uri, _counter) = spawn_token_server().await; + let repo = Arc::new(InMemoryClientRegistrationRepository::new([registration( + &token_uri, + )])); + let service = Arc::new(InMemoryOAuth2AuthorizedClientService::new()); + + // Pre-seed an expired client (principal = client id) holding a refresh token. + service + .save(OAuth2AuthorizedClient { + registration_id: "svc-reg".into(), + principal_name: "svc-client".into(), + access_token: "stale".into(), + refresh_token: Some("rt-x".into()), + expires_at: Some(1), // long past + scopes: vec!["api".into()], + }) + .await; + + let manager = OAuth2AuthorizedClientManager::new(repo, service); + let refreshed = manager + .authorize_client_credentials("svc-reg") + .await + .expect("refresh"); + assert_eq!(refreshed.access_token, "refreshed-1"); + // The refresh response omitted a new refresh token → the old one is kept. + assert_eq!(refreshed.refresh_token.as_deref(), Some("rt-x")); +} diff --git a/docs/book/dist/firefly-rust-by-example-es.epub b/docs/book/dist/firefly-rust-by-example-es.epub index 5743d35..de27722 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 643f191..95d6ee3 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 cc4bf71..83db3ff 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 31558fb..519b2e6 100644 Binary files a/docs/book/dist/firefly-rust-by-example.pdf and b/docs/book/dist/firefly-rust-by-example.pdf differ diff --git a/docs/book/src-es/14a-spring-security-parity.md b/docs/book/src-es/14a-spring-security-parity.md index d903d4a..dd44f53 100644 --- a/docs/book/src-es/14a-spring-security-parity.md +++ b/docs/book/src-es/14a-spring-security-parity.md @@ -42,7 +42,10 @@ En la columna **Estado**, :status-supported: indica una función soportada, | `RequestCache` / `SavedRequest` | :status-supported: | `HttpSessionRequestCache` — la página previa al login se restaura tras autenticarse (solo redirección del mismo origen) | | `SessionCreationPolicy` | :status-supported: | `Always`/`IfRequired`/`Never`/`Stateless`; `Stateless` instala el repositorio de contexto nulo para APIs de tokens | | Múltiples cadenas de filtros | :status-supported: | `SecurityFilterChains` — gana el primer `RequestMatcher` que coincide (el `FilterChainProxy` de Spring) | -| Cliente OAuth2 (`AuthorizedClientManager`) / Servidor de autorización | :status-planned: | Lado de login presente; cliente saliente y servidor de autorización montado en la hoja de ruta | +| Cliente OAuth2 saliente (`AuthorizedClientManager`) | :status-supported: | `OAuth2AuthorizedClientManager` + `OAuth2AuthorizedClientService` — grants client-credentials / refresh-token, caché de tokens y auto-refresco para llamadas salientes | +| Introspección de tokens opacos (RFC 7662) | :status-supported: | `RemoteTokenIntrospector` (`OpaqueTokenIntrospector`) — un `Verifier` de servidor de recursos intercambiable | +| Logout iniciado por RP (OIDC) | :status-supported: | `oidc_logout_url` — el logout redirige al `end_session_endpoint` del proveedor (`OidcClientInitiatedLogoutSuccessHandler`) | +| Servidor de autorización | :status-partial: | `AuthorizationServer` (client-credentials + refresh-token) montado vía `AuthorizationServerRouter` (`/oauth2/token`, metadatos RFC 8414); el grant authorization_code del lado servidor en la hoja de ruta | | ACL / seguridad de objetos de dominio · SAML2 · LDAP/AD | :status-planned: | Hoja de ruta (crates opcionales) | ## Comportamientos fieles a Spring que conviene conocer @@ -138,6 +141,32 @@ palabra clave (`role = "ADMIN"`, `any_authority = [..]`), aceptan Las cuatro fallan en cerrado: sin contexto ambiente se deniega con `Unauthenticated`, y una expresión falsa con `Forbidden`. +## Ecosistema OAuth2 + +Más allá del flujo de login en navegador (auth-code + PKCE + OIDC), Firefly +cubre el ecosistema OAuth2 más amplio: + +- **Introspección de tokens opacos (RFC 7662)** — `RemoteTokenIntrospector` + (el `OpaqueTokenIntrospector` de Spring) valida tokens bearer no-JWT contra el + endpoint `/introspect` del servidor de autorización y mapea la respuesta + `active` a un `Authentication`. Implementa `Verifier`, así que se conecta a un + `BearerLayer` como alternativa a la verificación JWT local. Falla en cerrado. +- **Cliente saliente (`AuthorizedClientManager`)** — + `OAuth2AuthorizedClientManager` + `OAuth2AuthorizedClientService` obtienen, + **cachean** y **auto-refrescan** los tokens de acceso que la app necesita para + llamar a servicios aguas abajo (client-credentials para servicio-a-servicio, + refresh-token para llamadas delegadas), reusando un token hasta que se acerca + su caducidad. +- **Logout iniciado por RP (OIDC)** — cuando el proveedor de login anuncia un + `end_session_endpoint`, `POST /logout` redirige el navegador allí con un + `id_token_hint` + `post_logout_redirect_uri` para que la sesión termine también + en el IdP (el `OidcClientInitiatedLogoutSuccessHandler` de Spring). +- **Servidor de autorización** — `AuthorizationServer` (client-credentials + + refresh-token, HS256) se monta sobre HTTP con `AuthorizationServerRouter`: + `POST /oauth2/token` (RFC 6749) y `GET /.well-known/oauth-authorization-server` + (metadatos RFC 8414). El grant authorization_code del lado servidor es un + seguimiento. + ## Login sin contraseña Firefly incluye los dos mecanismos sin contraseña de Spring Security 6.4: @@ -168,8 +197,9 @@ La paridad se entrega por niveles, cada uno un incremento: 4. **Profundidad de seguridad de método (hecho)** — 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. +5. **Ecosistema OAuth2 (hecho)** — introspección de tokens opacos (RFC 7662), + el gestor de clientes autorizados salientes, logout iniciado por RP, y el + servidor de autorización montado sobre HTTP con metadatos RFC 8414. (El grant + authorization_code del lado servidor queda como seguimiento.) 6. **Subsistemas grandes** — ACL / seguridad de objetos de dominio, LDAP / Active Directory, SAML2. diff --git a/docs/book/src/14a-spring-security-parity.md b/docs/book/src/14a-spring-security-parity.md index 83f3e24..10f9d43 100644 --- a/docs/book/src/14a-spring-security-parity.md +++ b/docs/book/src/14a-spring-security-parity.md @@ -41,7 +41,10 @@ In the **Status** column, :status-supported: marks a supported feature, | `RequestCache` / `SavedRequest` | :status-supported: | `HttpSessionRequestCache` — the pre-login page restored after authentication (same-origin redirect only) | | `SessionCreationPolicy` | :status-supported: | `Always`/`IfRequired`/`Never`/`Stateless`; `Stateless` installs the null context repository for token APIs | | Multiple filter chains | :status-supported: | `SecurityFilterChains` — first matching `RequestMatcher` wins (Spring's `FilterChainProxy`) | -| OAuth2 client (`AuthorizedClientManager`) / Authorization Server | :status-planned: | Login side present; outbound client + a mounted authorization server on the roadmap | +| Outbound OAuth2 client (`AuthorizedClientManager`) | :status-supported: | `OAuth2AuthorizedClientManager` + `OAuth2AuthorizedClientService` — client-credentials / refresh-token grants, token cache + auto-refresh for downstream calls | +| Opaque-token introspection (RFC 7662) | :status-supported: | `RemoteTokenIntrospector` (`OpaqueTokenIntrospector`) — a drop-in resource-server `Verifier` | +| RP-initiated logout (OIDC) | :status-supported: | `oidc_logout_url` — logout redirects to the provider `end_session_endpoint` (`OidcClientInitiatedLogoutSuccessHandler`) | +| Authorization server | :status-partial: | `AuthorizationServer` (client-credentials + refresh-token) mounted via `AuthorizationServerRouter` (`/oauth2/token`, RFC 8414 metadata); server-side authorization_code grant on the roadmap | | ACL / domain-object security · SAML2 · LDAP/AD | :status-planned: | Roadmap (opt-in crates) | ## Spring-faithful behaviours to know @@ -131,6 +134,30 @@ Rust analog of Spring's SpEL: All four fail closed: no ambient context denies with `Unauthenticated`, a false expression with `Forbidden`. +## OAuth2 ecosystem + +Beyond the browser login flow (auth-code + PKCE + OIDC), Firefly covers the +wider OAuth2 ecosystem: + +- **Opaque-token introspection (RFC 7662)** — `RemoteTokenIntrospector` + (Spring's `OpaqueTokenIntrospector`) validates non-JWT bearer tokens against + the authorization server's `/introspect` endpoint and maps the `active` + response to an `Authentication`. It implements `Verifier`, so it drops into a + `BearerLayer` as an alternative to local JWT verification. Fails closed. +- **Outbound client (`AuthorizedClientManager`)** — + `OAuth2AuthorizedClientManager` + `OAuth2AuthorizedClientService` obtain, + **cache**, and **auto-refresh** the access tokens the app needs to call + downstream services (client-credentials for service-to-service, refresh-token + for delegated calls), reusing a token until it nears expiry. +- **RP-initiated logout (OIDC)** — when the login provider advertises an + `end_session_endpoint`, `POST /logout` redirects the browser there with an + `id_token_hint` + `post_logout_redirect_uri` so the session ends at the IdP + too (Spring's `OidcClientInitiatedLogoutSuccessHandler`). +- **Authorization server** — `AuthorizationServer` (client-credentials + + refresh-token, HS256) is mounted over HTTP by `AuthorizationServerRouter`: + `POST /oauth2/token` (RFC 6749) and `GET /.well-known/oauth-authorization-server` + (RFC 8414 metadata). The server-side authorization_code grant is a follow-up. + ## Passwordless login Firefly ships the two Spring Security 6.4 passwordless mechanisms: @@ -159,8 +186,9 @@ Parity is delivered in tiers, each its own increment: chains. 4. **Method-security depth (done)** — 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. +5. **OAuth2 ecosystem (done)** — opaque-token introspection (RFC 7662), the + outbound authorized-client manager, RP-initiated logout, and the + authorization server mounted over HTTP with RFC 8414 metadata. (The + server-side authorization_code grant remains a follow-up.) 6. **Big subsystems** — ACL / domain-object security, LDAP / Active Directory, SAML2.