diff --git a/.gitignore b/.gitignore index 6d9b6ad..34f2131 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ sketchpad/ # Chat data **/chat_data/ + +# External security review input (not part of the published repo) +SECURITY_FINDINGS.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 603cbeb..0b0e4c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,70 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Changed - 2026-08-18 (`rest_service!` hardening — device-integration feedback) +- **`rest_service!` now requires `application/json` on bodied endpoints by default.** A request whose `Content-Type` is not `application/json` (parameters like `; charset=utf-8` are allowed) is rejected with `415 Unsupported Media Type` before the body is read. This forces a CORS preflight for cross-origin requests, closing the simple-request CSRF shape (a cross-origin `text/plain` POST), and matches `file_service!`, which already validated. **Breaking:** clients that POST/PUT/PATCH a body without an `application/json` content type now get `415`; opt out per-service with `require_json_content_type: false`. Rides in the already-unreleased `ras-rest-macro` `0.3.0`. + +### Added - 2026-08-18 (`rest_service!` hardening) +- **`require_json_content_type: `** service option (default `true`) — opt out of the strict `Content-Type` check above. +- **`docs_require_auth: `** service option (default `false`) — gate the generated docs page and `openapi.json` behind authentication (any authenticated user) when `serve_docs` is enabled. Previously these routes were always public, exposing method names, schemas, and permission requirements; they remain public by default (conventional API-explorer behavior) and are now documented as such. +- **Per-endpoint `body_limit: `** — override the service body limit for a single endpoint. +- **Per-endpoint `headers: true`** — pass the request `axum::http::HeaderMap` to the handler as an extra argument (after the caller/user, before path params), so header-derived data no longer requires a separate tower layer. + +### Fixed - 2026-08-18 (`rest_service!` hardening) +- **`204 No Content` no longer carries a serialized body.** `RestResponse::no_content()` previously emitted a `204` with a `null` JSON body and `Content-Type: application/json`, violating RFC 9110; `204`/`304` responses now have an empty body. +- **`413` (too large) is distinguished from `400` (unreadable body).** A body whose declared `Content-Length` exceeds the limit is rejected up front; an over-limit streamed body is `413`, while a genuine stream read error is now `400` — previously both were reported as `413`. +- **Body-decode failures are logged.** A malformed JSON body is logged at `warn` with the serde error category and line/column (never the rejected value) before the generic `400`, matching the handler-error logging convention. +- **Authorization rejections are observable.** `401`/`403`/`500` responses from the shared authorize pipeline are logged at `warn` (server-side detail only); previously rejections bypassed both the usage and duration trackers and were logged nowhere. +- **A permissioned service built without an auth provider now panics at `build()`** with a clear message instead of returning a runtime `500` (`NoAuthProvider`) on the first request. + +### Changed - 2026-08-18 (`jsonrpc_service!` parity) +- **`jsonrpc_service!` now requires `application/json` by default.** A request whose `Content-Type` is not `application/json` is rejected with `415` before the envelope is parsed. **Breaking** for clients that POST without that content type; opt out per-service with `require_json_content_type: false`. Rides in the already-unreleased `ras-jsonrpc-macro` `0.3.0`. +- `ras-jsonrpc-core` now depends on and re-exports `tracing` (`ras_jsonrpc_core::tracing`) so generated JSON-RPC server code can log without every consumer crate declaring a direct `tracing` dependency. Additive; folds into the already-unreleased `ras-jsonrpc-core` `0.2.0`. + +### Added - 2026-08-18 (`jsonrpc_service!` parity) +- **`require_json_content_type: `** service option (default `true`) — opt out of the strict `Content-Type` check. +- **`body_limit: `** service option (default 2 MiB) — cap the request body size via a `DefaultBodyLimit` layer. +- **`docs_require_auth: `** service option (default `false`) — gate the explorer page and `openrpc.json` behind authentication (any authenticated user) when the explorer is enabled. The RPC endpoint itself is never gated by this option. Previously these routes were always public. + +### Fixed - 2026-08-18 (`jsonrpc_service!` parity) +- **Malformed request bodies are logged.** A JSON parse failure is logged at `warn` with the serde error category and line/column (never the rejected value) before the `-32700` parse error. +- **Authorization rejections are observable.** `401`/`403` responses (authentication required, token expired, CSRF, insufficient permissions) are logged at `warn`; previously rejections were logged nowhere and bypassed the usage tracker. +- **`build()` now fails when a permissioned service (or a gated explorer) has no auth provider**, returning a clear `Err(String)` instead of silently rejecting every such call at runtime. +- **Dependency advisory:** bumped `h2` `0.4.13` → `0.4.16` for RUSTSEC-2026-0258 (unbounded empty DATA frames); `cargo deny check advisories` and `cargo audit` are clean again (transitive via the axum/hyper/reqwest HTTP stack). + +### Changed - 2026-08-18 (multi-agent review remediation) +- **Semver:** bumped six crates that re-export a bumped dependency in their public API — `ras-rest-core` `0.1.1` → `0.2.0`, `ras-file-core` `0.1.0` → `0.2.0`, `ras-observability-core` / `ras-observability-otel` `0.1.0` → `0.2.0`, `ras-jsonrpc-bidirectional-types` / `ras-jsonrpc-bidirectional-client` `0.1.0` → `0.2.0` — and cascaded the `{ path, version }` requirements. (Also records the earlier `ras-jsonrpc-bidirectional-macro` `0.1.0` → `0.2.0` bump for the M4 compile-error contract.) +- **REST generated code logs via `ras_rest_core::tracing`.** `ras-rest-core` now depends on and re-exports `tracing`, mirroring the JSON-RPC fix, so `rest_service!` consumers no longer need an undeclared direct `tracing` dependency. +- Corrected every documented dependency version pin (book pages + crate READMEs) to the current crate versions; stale pins would have resolved a pre-hardening macro or linked two incompatible copies of a core crate. + +### Fixed - 2026-08-18 (multi-agent review remediation) +- **Generated REST client tolerates an empty 204/304 success body.** A `204` on a non-unit response type now deserializes as `null` (so `Option` / `serde_json::Value` resolve to `None` / `Null`) instead of failing with a serde EOF error; the server also omits the body for `205 Reset Content`. +- **OAuth2 reserved-parameter denylist widened (H1).** `request`, `request_uri`, `response_mode`, `resource`, `audience`, and `id_token_hint` are now rejected in `additional_params` / provider `auth_params`, closing the OIDC request-object override path. +- **OAuth2 id_token `sub` is now required (M6).** `validate_id_token_claims` rejects an id_token without a `sub` claim, and the userinfo↔id_token subject binding fails closed instead of silently no-opping. +- **CSRF header names are validated (L2 follow-up).** `CsrfConfig::validate()` rejects a CORS-safelisted or browser-controlled header name (`accept`, `content-type`, `cookie`, …), which would otherwise satisfy the fail-closed cookie/CSRF check while providing no protection. +- Documentation fidelity: `ras-jsonrpc-types` README uses the single-argument `insufficient_permissions`; `ras-auth-core` README states the cookie-always-carries-CSRF invariant; the `docs_require_auth` browser-transport limitation, the Content-Type gate's bodied-endpoint scope, and the `headers: true` credential-exposure caveat are now documented. + +### Changed - 2026-08-12 (security review remediation) +- **Cookie auth now requires CSRF (H2).** `AuthTransportConfig::validate` rejects a config with a cookie transport and no CSRF config, and `with_cookie(...)` / the generated `auth_cookie(...)` builders now install a default double-submit `CsrfConfig` when none is set. There is no builder path to cookie auth without CSRF. Existing apps that enabled cookies and omitted CSRF will now fail at `build()`/`validate()` — this is intended. Bumped `ras-auth-core` `0.1.0` → `0.2.0`, `ras-rest-macro` `0.2.1` → `0.3.0`, `ras-jsonrpc-macro` `0.2.0` → `0.3.0`, `ras-file-macro` `0.1.0` → `0.2.0`. +- **Empty permission group mixed with non-empty groups no longer grants any authenticated user (M4).** `WITH_PERMISSIONS(["admin"] | [])` previously granted access to any logged-in user; it now denies at runtime (`check_permission_groups` / `user_satisfies_permission_groups` in `ras-auth-core`) and is a compile error in all four service macros. `WITH_PERMISSIONS([])` (authenticated-only) is unchanged. Generated-code contract change on the REST/JSON-RPC/file/bidirectional macros. +- **JSON-RPC `-32002` no longer returns the caller's permission set (M1).** `JsonRpcError::insufficient_permissions` now takes only `required` and omits `has` from the error `data`; the caller's grant set is never echoed. Public JSON shape and function-signature change. Bumped `ras-jsonrpc-types` `0.1.1` → `0.2.0`, `ras-jsonrpc-core` `0.1.2` → `0.2.0`. +- **WebSocket JSON-RPC and upgrade errors are sanitized (H3).** Handler and `AuthError` internals are no longer stringified onto the wire; `ServerError::client_message()` returns a generic per-class message (the full error is logged server-side). Matches the HTTP JSON-RPC / REST sanitization. Bumped `ras-jsonrpc-bidirectional-server` `0.1.0` → `0.2.0`. +- **WebSocket credential extraction matches HTTP (M5).** Only `Authorization: Bearer ` (case-insensitive, non-empty) is treated as a bearer token; raw values and non-Bearer schemes are rejected and a malformed header no longer falls through to a weaker transport. Client-claimed IP metadata is relabelled `claimed_client_ip` (untrusted). +- **OAuth2 default start-flow binds against login CSRF (M2).** `OAuth2Provider::start_flow` now generates a session binding and returns it in `OAuth2Response::AuthorizationUrl { url, state, binding }` (new field); the callback must echo it. `start_flow_bound(.., None)` remains the explicit unbound escape hatch. Bumped `ras-identity-oauth2` `0.1.2` → `0.2.0`. +- **OAuth2 reserved-parameter injection blocked (H1).** `additional_params` / provider `auth_params` can no longer override reserved OAuth/OIDC parameters (`redirect_uri`, `state`, PKCE, etc.); a collision returns the new `OAuth2Error::InvalidAuthorizationParam`. +- **OAuth2 ID-token / client hardening (M6).** `issuer` is now required (fail-closed) to accept an id_token; userinfo `sub` must match the id_token `sub`; multi-audience tokens require a matching `azp`; `OAuth2Client::new` no longer silently falls back to a timeout-less HTTP client (it now panics — use `try_new`). +- **JWT sessions gain optional `iss`/`aud` (M3).** `SessionConfig` and `JwtClaims` carry optional `iss`/`aud`; when configured they are encoded and verified, rejecting cross-service token reuse. Removed the unused `refresh_enabled` flag (no refresh-token rotation exists). Bumped `ras-identity-session` `0.2.0` → `0.3.0`. + +### Fixed - 2026-08-12 (security review remediation) +- **Secrets no longer appear in `Debug` (L1).** `SessionConfig` (`jwt_secret`), `OAuth2ProviderConfig` (`client_secret`), and `LocalUser` (`password_hash`) now use redacting `Debug` impls. OAuth2 token-exchange / userinfo error paths log the status code only, not the response body. Bumped `ras-identity-local` `0.2.0` → `0.2.1`. +- **CSRF token comparison is constant-time (L2).** `CsrfConfig::validate_headers` uses `subtle::ConstantTimeEq` (new direct dependency of `ras-auth-core`). +- **Dependency advisories (D1).** `cargo update` bumped `crossbeam-epoch` (`0.9.18` → `0.9.20`), `quinn-proto` (`0.11.14` → `0.11.16`), and `spin` (`0.9.8` → `0.9.9`); `cargo deny check advisories` and `cargo audit` are clean. `lru`/`paste` warnings remain (transitive via `ratatui` in the TUI example only; not compiled into any library crate). +- **oauth2-demo hardened (H4).** Dropped the client-controlled `additional_params`; the JWT is delivered in the URL fragment (never sent to the server / not in `Referer`) and scrubbed from the URL immediately instead of the query string; a login-CSRF binding cookie is set and verified; `enforce_active_sessions` is enabled; CORS is restricted to the demo origin; and admin permissions are only granted on a verified email. + +### Documentation - 2026-08-12 +- `identity-and-sessions.md` and crate READMEs describe cookie auth as cookie **and** CSRF (H2) and the bound OAuth2 flow as the primary path (M2). +- The root README's "Rate Limiting" bullet is corrected: the local-auth `Semaphore` is a concurrency bound, not a rate limiter (L3). + ### Added - 2026-06-29 - New `OPTIONAL_AUTH` route level for `rest_service!`, `file_service!`, `jsonrpc_service!`, and `jsonrpc_bidirectional_service!`. An `OPTIONAL_AUTH` route is public — never rejected for auth reasons — but opportunistically identifies its caller: the handler receives a `ras_auth_core::Caller` (`Anonymous` / `Authenticated(user)`) as its first argument (the file service surfaces it through `FileRequestContext`). Resolution is fully lenient: a missing, invalid, or expired credential, or a cookie that fails CSRF on an unsafe method, resolves to `Caller::Anonymous` rather than a 401/403. - `ras-auth-core`: new `Caller` enum (`#[must_use]`) and non-rejecting `resolve_caller` resolver alongside `authorize_request`. diff --git a/Cargo.lock b/Cargo.lock index 63726f7..820a7b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,7 +64,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", "blake2", - "cpufeatures", + "cpufeatures 0.2.17", "password-hash", ] @@ -455,6 +455,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.44" @@ -639,6 +650,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "criterion" version = "0.5.1" @@ -689,9 +709,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] @@ -1033,7 +1053,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1314,11 +1334,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] @@ -1328,10 +1346,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -1346,9 +1367,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.13" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", @@ -1749,7 +1770,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1980,7 +2001,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2147,9 +2168,9 @@ dependencies = [ [[package]] name = "opentelemetry_sdk" -version = "0.32.0" +version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368afaed344110f40b179bb8fbe54bc52d98f9bd2b281799ef32487c2650c956" +checksum = "9b59f80e1ac4d5ff7a2db8fb6c80badb7f0f3f858211fba08dd9aaec750894f9" dependencies = [ "futures-channel", "futures-executor", @@ -2524,14 +2545,15 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ "bytes", - "getrandom 0.3.4", + "getrandom 0.4.2", "lru-slab", - "rand 0.9.4", + "rand 0.10.2", + "rand_pcg", "ring", "rustc-hash", "rustls", @@ -2554,7 +2576,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -2599,6 +2621,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -2637,21 +2670,37 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "ras-auth-core" -version = "0.1.0" +version = "0.2.0" dependencies = [ "cookie", "http", "serde", "serde_json", + "subtle", "thiserror 2.0.18", "tokio", ] [[package]] name = "ras-file-core" -version = "0.1.0" +version = "0.2.0" dependencies = [ "bytes", "futures-core", @@ -2663,7 +2712,7 @@ dependencies = [ [[package]] name = "ras-file-macro" -version = "0.1.0" +version = "0.2.0" dependencies = [ "async-trait", "axum", @@ -2699,7 +2748,7 @@ dependencies = [ [[package]] name = "ras-identity-local" -version = "0.2.0" +version = "0.2.1" dependencies = [ "argon2", "async-trait", @@ -2712,7 +2761,7 @@ dependencies = [ [[package]] name = "ras-identity-oauth2" -version = "0.1.2" +version = "0.2.0" dependencies = [ "async-trait", "axum", @@ -2736,7 +2785,7 @@ dependencies = [ [[package]] name = "ras-identity-session" -version = "0.2.0" +version = "0.3.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -2755,7 +2804,7 @@ dependencies = [ [[package]] name = "ras-jsonrpc-bidirectional-client" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "async-trait", @@ -2784,7 +2833,7 @@ dependencies = [ [[package]] name = "ras-jsonrpc-bidirectional-macro" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "async-trait", @@ -2814,7 +2863,7 @@ dependencies = [ [[package]] name = "ras-jsonrpc-bidirectional-server" -version = "0.1.0" +version = "0.2.0" dependencies = [ "async-trait", "axum", @@ -2834,7 +2883,7 @@ dependencies = [ [[package]] name = "ras-jsonrpc-bidirectional-types" -version = "0.1.0" +version = "0.2.0" dependencies = [ "async-trait", "chrono", @@ -2852,7 +2901,7 @@ dependencies = [ [[package]] name = "ras-jsonrpc-core" -version = "0.1.2" +version = "0.2.0" dependencies = [ "ras-auth-core", "ras-jsonrpc-types", @@ -2860,11 +2909,12 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.18", + "tracing", ] [[package]] name = "ras-jsonrpc-macro" -version = "0.2.0" +version = "0.3.0" dependencies = [ "async-trait", "axum", @@ -2891,7 +2941,7 @@ dependencies = [ [[package]] name = "ras-jsonrpc-types" -version = "0.1.1" +version = "0.2.0" dependencies = [ "serde", "serde_json", @@ -2899,7 +2949,7 @@ dependencies = [ [[package]] name = "ras-observability-core" -version = "0.1.0" +version = "0.2.0" dependencies = [ "async-trait", "axum", @@ -2911,7 +2961,7 @@ dependencies = [ [[package]] name = "ras-observability-otel" -version = "0.1.0" +version = "0.2.0" dependencies = [ "async-trait", "axum", @@ -2949,17 +2999,18 @@ dependencies = [ [[package]] name = "ras-rest-core" -version = "0.1.1" +version = "0.2.0" dependencies = [ "ras-auth-core", "ras-version-core", "serde", "thiserror 2.0.18", + "tracing", ] [[package]] name = "ras-rest-macro" -version = "0.2.1" +version = "0.3.0" dependencies = [ "async-trait", "axum", @@ -3289,7 +3340,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3502,7 +3553,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3513,7 +3564,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3593,9 +3644,9 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" [[package]] name = "stable_deref_trait" @@ -3684,7 +3735,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4450,7 +4501,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4524,7 +4575,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -4533,16 +4584,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -4560,31 +4602,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -4593,96 +4618,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "0.7.15" diff --git a/Cargo.toml b/Cargo.toml index 8180207..9480738 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,7 @@ schemars = "1.0.0-alpha.20" serde_json = "1.0" serde_urlencoded = "0.7" sha2 = "0.10" +subtle = "2.6" tempfile = "3.13" thiserror = "2.0" tokio-tungstenite = "0.26" diff --git a/README.md b/README.md index c7c6547..27faeb2 100644 --- a/README.md +++ b/README.md @@ -289,7 +289,7 @@ Package-level README files remain available for crate-specific details: ### Authentication & Security - **Timing Attack Mitigation** - Missing local users verify against an Argon2 sentinel hash - **Username Enumeration Mitigation** - Uniform invalid-credentials errors -- **Rate Limiting** - Local authentication limits concurrent verification attempts +- **Concurrency Bound** - Local authentication caps *concurrent* verification attempts (a Semaphore), which is not a rate limiter; deploy a per-user/per-IP rate limiter at the HTTP edge for brute-force protection - **Password Storage** - Per-user salted Argon2id hashes - **JWT Configuration** - Configurable algorithms, secrets, TTLs, and active-session enforcement - **PKCE OAuth2** - Proof Key for Code Exchange by default diff --git a/crates/core/ras-auth-core/Cargo.toml b/crates/core/ras-auth-core/Cargo.toml index 5d85cfc..3d485bd 100644 --- a/crates/core/ras-auth-core/Cargo.toml +++ b/crates/core/ras-auth-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ras-auth-core" -version = "0.1.0" +version = "0.2.0" edition = "2024" rust-version = "1.88" description = "Core authentication and authorization traits for Rust Agent Stack services" @@ -14,6 +14,7 @@ cookie = { workspace = true } http = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +subtle = { workspace = true } thiserror = { workspace = true } [dev-dependencies] diff --git a/crates/core/ras-auth-core/README.md b/crates/core/ras-auth-core/README.md index 0620088..713c7df 100644 --- a/crates/core/ras-auth-core/README.md +++ b/crates/core/ras-auth-core/README.md @@ -115,6 +115,15 @@ Bearer tokens remain enabled by default. If both `Authorization: Bearer ...` and the configured cookie are present, bearer wins. If the bearer header is present but malformed, the request fails instead of falling back to the cookie. +**Cookie auth always carries CSRF.** `with_cookie(...)` installs a default +`CsrfConfig` when none is set, and `AuthTransportConfig::validate()` fails closed +if a cookie transport has no CSRF config — so there is no way to enable cookie +auth without a CSRF guard (the `.with_csrf(...)` call above is therefore +optional). `CsrfConfig::validate()` also rejects a CORS-safelisted or +browser-controlled header name (`accept`, `content-type`, `cookie`, …), since +only a custom header forces the CORS preflight that makes the double-submit +check meaningful. + Cookie helpers emit secure defaults: ```rust diff --git a/crates/core/ras-auth-core/src/authorize.rs b/crates/core/ras-auth-core/src/authorize.rs index e380cc6..0ba21c3 100644 --- a/crates/core/ras-auth-core/src/authorize.rs +++ b/crates/core/ras-auth-core/src/authorize.rs @@ -31,9 +31,13 @@ pub enum AuthorizeError { /// /// `groups` is a disjunction of conjunctions: access is granted when the user /// holds every permission of at least one group (verified through the -/// provider's `check_permissions`, which custom providers may override). A -/// group list with no non-empty groups — `WITH_PERMISSIONS([])` or any empty -/// inner group — grants access to any authenticated user. +/// provider's `check_permissions`, which custom providers may override). +/// +/// An empty group means "any authenticated user", but *only* as the entire +/// requirement — `WITH_PERMISSIONS([])`, i.e. no groups or only empty groups. +/// An empty group mixed with non-empty siblings (`["admin"] | []`) is ignored +/// here rather than treated as a blanket grant; the service macros reject that +/// shape at compile time, and this runtime guard is the belt-and-suspenders. pub fn check_permission_groups

( provider: &P, user: &AuthenticatedUser, @@ -42,12 +46,12 @@ pub fn check_permission_groups

( where P: AuthProvider + ?Sized, { - if !groups.iter().any(|group| !group.is_empty()) { + if groups.iter().all(|group| group.is_empty()) { return Ok(()); } for group in groups { - if group.is_empty() || provider.check_permissions(user, group).is_ok() { + if !group.is_empty() && provider.check_permissions(user, group).is_ok() { return Ok(()); } } @@ -66,14 +70,13 @@ where /// an auth provider (e.g. the bidirectional WebSocket handler, which /// authorizes against the cached connection user). pub fn user_satisfies_permission_groups(user: &AuthenticatedUser, groups: &[Vec]) -> bool { - if !groups.iter().any(|group| !group.is_empty()) { + if groups.iter().all(|group| group.is_empty()) { return true; } groups .iter() .any(|group| !group.is_empty() && group.iter().all(|perm| user.permissions.contains(perm))) - || groups.iter().any(|group| group.is_empty()) } /// The credential → CSRF → authenticate → permission pipeline shared by the @@ -194,8 +197,21 @@ mod tests { } #[test] - fn empty_inner_group_grants_any_authenticated_user() { + fn empty_inner_group_mixed_with_non_empty_does_not_grant_any_authenticated_user() { + // `["admin"] | []` must NOT collapse to authenticated-only. A user with + // no permissions is denied; only a user actually holding `admin` passes. let g = groups(&[&["admin"], &[]]); + assert!(check_permission_groups(&StaticProvider, &user(&[]), &g).is_err()); + assert!(!user_satisfies_permission_groups(&user(&[]), &g)); + + assert!(check_permission_groups(&StaticProvider, &user(&["admin"]), &g).is_ok()); + assert!(user_satisfies_permission_groups(&user(&["admin"]), &g)); + } + + #[test] + fn only_empty_groups_are_authenticated_only() { + // `[[]]` — a single empty group — remains authenticated-only, same as `[]`. + let g = groups(&[&[]]); assert!(check_permission_groups(&StaticProvider, &user(&[]), &g).is_ok()); assert!(user_satisfies_permission_groups(&user(&[]), &g)); } diff --git a/crates/core/ras-auth-core/src/transport.rs b/crates/core/ras-auth-core/src/transport.rs index 5d797e0..51183a2 100644 --- a/crates/core/ras-auth-core/src/transport.rs +++ b/crates/core/ras-auth-core/src/transport.rs @@ -6,12 +6,36 @@ use cookie::{ }; use http::header::{AUTHORIZATION, COOKIE, HeaderName, SET_COOKIE}; use http::{HeaderMap, HeaderValue}; +use subtle::ConstantTimeEq; use thiserror::Error; const DEFAULT_COOKIE_NAME: &str = "__Host-ras-session"; const DEFAULT_CSRF_COOKIE_NAME: &str = "__Host-ras-csrf"; const DEFAULT_CSRF_HEADER: &str = "x-ras-csrf"; +/// Header names that provide no CSRF protection because a browser either sends +/// them automatically cross-origin (CORS-safelisted request headers) or +/// populates them itself (forbidden headers a page cannot control). A CSRF +/// header must be a custom header, since only a custom header forces a CORS +/// preflight that a cross-site attacker cannot satisfy. +const CSRF_UNSAFE_HEADER_NAMES: &[&str] = &[ + // CORS-safelisted request headers — sent cross-origin without a preflight. + "accept", + "accept-language", + "content-language", + "content-type", + // Browser-controlled / forbidden headers — auto-sent, not page-settable. + "cookie", + "origin", + "referer", + "host", + "user-agent", + "content-length", + "connection", + "accept-encoding", + "date", +]; + /// Source from which an authentication token was extracted. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AuthTokenSource { @@ -398,6 +422,23 @@ impl CsrfConfig { /// Validate CSRF configuration. pub fn validate(&self) -> Result<(), AuthTransportError> { + // A CORS-safelisted or browser-controlled header name provides zero CSRF + // protection (it is sent automatically cross-origin), so reject it — + // otherwise `header_presence_only(HeaderName::from_static("accept"))` + // would produce a config that passes validation but never blocks a + // forged request. + let header = self.header_name.as_str(); + if CSRF_UNSAFE_HEADER_NAMES + .iter() + .any(|name| header.eq_ignore_ascii_case(name)) + { + return Err(AuthTransportError::InvalidCsrfConfig(format!( + "CSRF header `{header}` is CORS-safelisted or browser-controlled \ + and provides no protection; use a custom header name (e.g. \ + `x-csrf-token`)" + ))); + } + if let Some(expected) = &self.expected_value && expected.trim().is_empty() { @@ -433,7 +474,7 @@ impl CsrfConfig { } if let Some(expected) = &self.expected_value - && value != expected + && !ct_eq_str(value, expected) { return Err(AuthTransportError::CsrfValidationFailed); } @@ -447,7 +488,7 @@ impl CsrfConfig { return Err(AuthTransportError::CsrfValidationFailed); }; - if cookie_value.trim().is_empty() || cookie_value != value { + if cookie_value.trim().is_empty() || !ct_eq_str(&cookie_value, value) { return Err(AuthTransportError::CsrfValidationFailed); } } @@ -495,8 +536,16 @@ impl Default for AuthTransportConfig { impl AuthTransportConfig { /// Enable cookie auth alongside the default bearer transport. + /// + /// Cookie credentials are vulnerable to CSRF on unsafe methods, so this also + /// installs a default double-submit [`CsrfConfig`] when none is configured + /// yet. Override it with [`Self::with_csrf`] if you need a different policy; + /// there is intentionally no builder path to cookie auth without CSRF. pub fn with_cookie(mut self, cookie: AuthCookieConfig) -> Self { self.cookie = Some(cookie); + if self.csrf.is_none() { + self.csrf = Some(CsrfConfig::default()); + } self } @@ -520,6 +569,18 @@ impl AuthTransportConfig { )); } + // Cookie credentials are automatically attached by the browser, so + // cookie auth without a CSRF guard lets any cross-site request act as + // the victim on unsafe methods. `with_cookie` installs a default CSRF + // config; a struct literal that clears it must fail closed here. + if self.cookie.is_some() && self.csrf.is_none() { + return Err(AuthTransportError::InvalidAuthTransportConfig( + "cookie auth requires a CSRF configuration; use with_cookie (which sets a \ + default double-submit CsrfConfig) or with_csrf" + .to_string(), + )); + } + if let Some(cookie) = &self.cookie { cookie.validate()?; } @@ -635,6 +696,14 @@ fn redact_header(headers: &mut HeaderMap, name: HeaderName) { } } +/// Constant-time string comparison for CSRF tokens. +/// +/// Length is allowed to leak (subtle short-circuits on differing lengths), but +/// equal-length values are compared without an input-dependent early return. +fn ct_eq_str(a: &str, b: &str) -> bool { + a.as_bytes().ct_eq(b.as_bytes()).into() +} + fn is_unsafe_method(method: &str) -> bool { matches!( method.to_ascii_uppercase().as_str(), @@ -928,6 +997,78 @@ mod tests { assert_eq!(redacted.get("user-agent").unwrap(), "test-agent"); } + #[test] + fn with_cookie_installs_default_csrf_and_validates() { + let config = AuthTransportConfig::default().with_cookie(AuthCookieConfig::default()); + + assert!(config.csrf.is_some()); + assert!(config.validate().is_ok()); + } + + #[test] + fn csrf_config_rejects_cors_safelisted_header_names() { + // A safelisted / browser-controlled header name provides no CSRF + // protection and must fail validation even though it is "present". + for name in ["accept", "content-type", "Accept-Language", "cookie", "origin"] { + let csrf = CsrfConfig::header_presence_only(HeaderName::from_bytes(name.as_bytes()).unwrap()); + let error = csrf.validate().expect_err(name); + assert!( + matches!(error, AuthTransportError::InvalidCsrfConfig(_)), + "{name} should be rejected" + ); + } + + // A genuinely custom header (forces a CORS preflight) is accepted. + let ok = CsrfConfig::header_presence_only(HeaderName::from_static("x-csrf-token")); + assert!(ok.validate().is_ok()); + } + + #[test] + fn cookie_without_csrf_fails_validate() { + let config = AuthTransportConfig { + bearer: true, + cookie: Some(AuthCookieConfig::default()), + csrf: None, + }; + + let error = config.validate().unwrap_err(); + + assert!(matches!( + error, + AuthTransportError::InvalidAuthTransportConfig(_) + )); + } + + #[test] + fn with_cookie_default_still_requires_csrf_header_on_unsafe_cookie_request() { + let config = AuthTransportConfig::default().with_cookie(AuthCookieConfig::default()); + let cookie = AuthCredential::new("cookie-token", AuthTokenSource::Cookie); + + // No CSRF header present -> unsafe cookie request is rejected. + assert_eq!( + validate_csrf_for_credential("POST", &HeaderMap::new(), &cookie, &config).unwrap_err(), + AuthTransportError::CsrfValidationFailed + ); + + // Bearer credentials stay exempt even on unsafe methods. + let bearer = AuthCredential::new("bearer-token", AuthTokenSource::Bearer); + assert!( + validate_csrf_for_credential("POST", &HeaderMap::new(), &bearer, &config).is_ok() + ); + + // GET cookie requests stay exempt. + assert!( + validate_csrf_for_credential("GET", &HeaderMap::new(), &cookie, &config).is_ok() + ); + + // Valid double-submit header + cookie passes. + let headers = headers(&[ + (DEFAULT_CSRF_HEADER, "csrf-token"), + ("cookie", "__Host-ras-csrf=csrf-token"), + ]); + assert!(validate_csrf_for_credential("POST", &headers, &cookie, &config).is_ok()); + } + #[test] fn redact_sensitive_headers_for_auth_transport_removes_custom_csrf_header() { let csrf_header = HeaderName::from_static("x-custom-csrf"); diff --git a/crates/core/ras-observability-core/Cargo.toml b/crates/core/ras-observability-core/Cargo.toml index 4708872..f9fc64e 100644 --- a/crates/core/ras-observability-core/Cargo.toml +++ b/crates/core/ras-observability-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ras-observability-core" -version = "0.1.0" +version = "0.2.0" edition = "2024" rust-version = "1.88" description = "Core traits and types for observability in Rust Agent Stack" @@ -10,7 +10,7 @@ homepage = "https://github.com/JedimEmO/rust-api-stack" readme = "README.md" [dependencies] -ras-auth-core = { path = "../ras-auth-core", version = "0.1.0" } +ras-auth-core = { path = "../ras-auth-core", version = "0.2.0" } async-trait = { workspace = true } serde = { workspace = true } axum = { workspace = true } diff --git a/crates/identity/ras-identity-local/Cargo.toml b/crates/identity/ras-identity-local/Cargo.toml index b85c0bd..6f0e980 100644 --- a/crates/identity/ras-identity-local/Cargo.toml +++ b/crates/identity/ras-identity-local/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ras-identity-local" -version = "0.2.0" +version = "0.2.1" edition = "2024" rust-version = "1.88" description = "Local username/password authentication provider with Argon2 hashing" diff --git a/crates/identity/ras-identity-local/README.md b/crates/identity/ras-identity-local/README.md index 5124306..c10982e 100644 --- a/crates/identity/ras-identity-local/README.md +++ b/crates/identity/ras-identity-local/README.md @@ -68,9 +68,10 @@ provider let session_service = Arc::new(SessionService::new(SessionConfig { jwt_secret: "use-at-least-32-bytes-of-random-secret".to_string(), jwt_ttl: Duration::hours(1), - refresh_enabled: false, enforce_active_sessions: true, algorithm: JwtAlgorithm::HS256, + iss: None, + aud: None, })?); session_service.register_provider(Box::new(provider)).await; diff --git a/crates/identity/ras-identity-local/src/lib.rs b/crates/identity/ras-identity-local/src/lib.rs index 4420f6f..2af4f3b 100644 --- a/crates/identity/ras-identity-local/src/lib.rs +++ b/crates/identity/ras-identity-local/src/lib.rs @@ -14,7 +14,7 @@ use std::fmt; use std::sync::Arc; use tokio::sync::RwLock; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Clone, Serialize, Deserialize)] pub struct LocalUser { pub username: String, pub password_hash: String, @@ -23,6 +23,19 @@ pub struct LocalUser { pub metadata: Option, } +/// Redacting `Debug` so the Argon2 `password_hash` never lands in logs (L1). +impl fmt::Debug for LocalUser { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("LocalUser") + .field("username", &self.username) + .field("password_hash", &"[REDACTED]") + .field("email", &self.email) + .field("display_name", &self.display_name) + .field("metadata", &self.metadata) + .finish() + } +} + #[derive(Debug, Serialize, Deserialize)] pub struct LocalAuthPayload { pub username: String, @@ -188,6 +201,22 @@ impl IdentityProvider for LocalUserProvider { mod tests { use super::*; + #[test] + fn debug_redacts_password_hash() { + let user = LocalUser { + username: "alice".to_string(), + password_hash: "$argon2id$v=19$m=19456,t=2,p=1$secretsecret$hashhashhash".to_string(), + email: None, + display_name: None, + metadata: None, + }; + let debug = format!("{user:?}"); + assert!(!debug.contains("hashhashhash")); + assert!(!debug.contains("$argon2id$")); + assert!(debug.contains("[REDACTED]")); + assert!(debug.contains("alice")); + } + async fn setup_test_provider() -> LocalUserProvider { let provider = LocalUserProvider::new(); diff --git a/crates/identity/ras-identity-oauth2/Cargo.toml b/crates/identity/ras-identity-oauth2/Cargo.toml index e36cbad..1bf4249 100644 --- a/crates/identity/ras-identity-oauth2/Cargo.toml +++ b/crates/identity/ras-identity-oauth2/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ras-identity-oauth2" -version = "0.1.2" +version = "0.2.0" edition = "2024" rust-version = "1.88" description = "OAuth2 authentication provider with Google support, PKCE, and state management" @@ -34,4 +34,4 @@ axum-test = { workspace = true } tracing-subscriber = { workspace = true } # For the example -ras-identity-session = { path = "../ras-identity-session", version = "0.2.0" } +ras-identity-session = { path = "../ras-identity-session", version = "0.3.0" } diff --git a/crates/identity/ras-identity-oauth2/README.md b/crates/identity/ras-identity-oauth2/README.md index 0f5d42c..b9d8f78 100644 --- a/crates/identity/ras-identity-oauth2/README.md +++ b/crates/identity/ras-identity-oauth2/README.md @@ -72,28 +72,36 @@ let session_service = SessionService::new(session_config)?; session_service.register_provider(Box::new(oauth2_provider.clone())).await; -// Start OAuth2 flow +// Start OAuth2 flow. `start_flow` always generates a login-CSRF `binding`; +// store it in a cookie and echo it back on the callback (a callback without it +// is rejected). match oauth2_provider.start_flow("google", None).await? { - OAuth2Response::AuthorizationUrl { url, state } => { - // Redirect user to `url` + OAuth2Response::AuthorizationUrl { url, state, binding } => { + // 1. Set `binding` in a Secure, HttpOnly cookie on the redirect response. + // 2. Redirect the user to `url`. println!("Redirect to: {}", url); + let _ = (state, binding); } OAuth2Response::Error { message } => { eprintln!("OAuth2 start-flow failed: {message}"); } } -// Handle callback +// Handle callback — read `binding` back from the cookie and include it. let callback_payload = serde_json::json!({ "type": "Callback", "provider_id": "google", "code": "authorization_code_from_callback", - "state": "state_from_callback" + "state": "state_from_callback", + "binding": "binding_from_cookie" }); let jwt_token = session_service.begin_session("oauth2", callback_payload).await?; ``` +For a non-browser flow where login CSRF does not apply, use +`start_flow_bound(provider_id, params, None)` to opt out of binding explicitly. + ## OAuth2 Flow 1. **Start Flow**: Client requests authorization URL diff --git a/crates/identity/ras-identity-oauth2/examples/google_oauth2.rs b/crates/identity/ras-identity-oauth2/examples/google_oauth2.rs index 4f0ea5b..114a340 100644 --- a/crates/identity/ras-identity-oauth2/examples/google_oauth2.rs +++ b/crates/identity/ras-identity-oauth2/examples/google_oauth2.rs @@ -69,16 +69,21 @@ async fn main() -> Result<(), Box> { println!("\n1. Starting OAuth2 flow..."); match oauth2_provider.start_flow("google", None).await { - Ok(OAuth2Response::AuthorizationUrl { url, state }) => { + Ok(OAuth2Response::AuthorizationUrl { + url, + state, + binding, + }) => { println!("Authorization URL: {}", url); println!("State: {}", state); println!("\nIn a real application, you would:"); - println!("1. Redirect the user to the authorization URL"); - println!("2. Handle the callback with the authorization code"); + println!("1. Store `binding` in a cookie and redirect the user to the URL"); + println!("2. Handle the callback, reading `binding` back from the cookie"); println!("3. Exchange the code for a JWT token"); - // Simulate callback (in real app, this comes from OAuth2 provider) - simulate_callback(&session_service, state).await?; + // Simulate callback (in real app, this comes from OAuth2 provider). + // The binding cookie is echoed back on the callback payload. + simulate_callback(&session_service, state, binding).await?; } Ok(OAuth2Response::Error { message }) => { println!("OAuth2 error: {}", message); @@ -94,15 +99,18 @@ async fn main() -> Result<(), Box> { async fn simulate_callback( session_service: &SessionService, state: String, + binding: Option, ) -> Result<(), Box> { println!("\n2. Simulating OAuth2 callback..."); - // In a real application, these values would come from the OAuth2 provider callback + // In a real application, these values would come from the OAuth2 provider + // callback; `binding` would be read back from the cookie set at start. let callback_payload = serde_json::json!({ "type": "Callback", "provider_id": "google", "code": "simulated_authorization_code", - "state": state + "state": state, + "binding": binding }); match session_service diff --git a/crates/identity/ras-identity-oauth2/src/client.rs b/crates/identity/ras-identity-oauth2/src/client.rs index 831be92..6177396 100644 --- a/crates/identity/ras-identity-oauth2/src/client.rs +++ b/crates/identity/ras-identity-oauth2/src/client.rs @@ -44,9 +44,13 @@ impl OAuth2HttpTransport for ReqwestOAuth2HttpTransport { let response = self.client.post(token_endpoint).form(params).send().await?; if !response.status().is_success() { - let error_text = response.text().await.unwrap_or_default(); - error!("Token exchange failed: {}", error_text); - return Err(OAuth2Error::TokenExchangeFailed(error_text)); + // Never log or propagate the raw provider response body — it can + // contain tokens or other sensitive material (L1). Status only. + let status = response.status(); + error!("Token exchange failed with status {}", status); + return Err(OAuth2Error::TokenExchangeFailed(format!( + "token endpoint returned status {status}" + ))); } let token_response: TokenResponse = response @@ -71,9 +75,12 @@ impl OAuth2HttpTransport for ReqwestOAuth2HttpTransport { .await?; if !response.status().is_success() { - let error_text = response.text().await.unwrap_or_default(); - error!("User info request failed: {}", error_text); - return Err(OAuth2Error::UserInfoFailed(error_text)); + // Status only; the raw body may echo the bearer token (L1). + let status = response.status(); + error!("User info request failed with status {}", status); + return Err(OAuth2Error::UserInfoFailed(format!( + "userinfo endpoint returned status {status}" + ))); } let user_info: UserInfoResponse = response @@ -130,6 +137,55 @@ impl PkceChallenge { } } +/// Reserved OAuth/OIDC query parameters that the library sets itself. Neither +/// `provider_config.auth_params` nor caller-supplied `additional_params` may +/// override them — many providers honour the last occurrence of a duplicated +/// query parameter, so an injected second `redirect_uri` / `state` / PKCE value +/// would be an authorization-code-theft or CSRF vector (H1). +const RESERVED_AUTH_PARAMS: &[&str] = &[ + "response_type", + "client_id", + "client_secret", + "redirect_uri", + "state", + "nonce", + "scope", + "code_challenge", + "code_challenge_method", + "grant_type", + "code", + "code_verifier", + // OIDC request objects (Core §6): parameters inside a `request` / + // `request_uri` JWT take precedence over the query parameters we set, so + // permitting them would re-establish the exact override primitive this + // denylist removes (e.g. silently dropping PKCE or overriding state/nonce). + "request", + "request_uri", + // Response delivery / audience controls a caller must not influence. + "response_mode", + "resource", + "audience", + "id_token_hint", +]; + +/// Reject any key that collides (case-insensitively) with a reserved parameter. +fn reject_reserved_params<'a, I>(keys: I, source: &str) -> OAuth2Result<()> +where + I: IntoIterator, +{ + for key in keys { + if RESERVED_AUTH_PARAMS + .iter() + .any(|reserved| reserved.eq_ignore_ascii_case(key)) + { + return Err(OAuth2Error::InvalidAuthorizationParam(format!( + "{source} may not set the reserved parameter `{key}`" + ))); + } + } + Ok(()) +} + /// OAuth2 client for handling authorization flows #[derive(Clone)] pub struct OAuth2Client { @@ -139,31 +195,19 @@ pub struct OAuth2Client { } impl OAuth2Client { + /// Infallible constructor. + /// + /// Panics if the HTTP client cannot be built. It never silently falls back + /// to an unbounded (timeout-less) client — a hung token/userinfo endpoint + /// would otherwise stall the flow indefinitely (M6). Use [`Self::try_new`] + /// to handle the (near-impossible) build error yourself. pub fn new( state_store: Arc, state_ttl_seconds: u64, http_timeout_seconds: u64, ) -> Self { - match Self::try_new( - Arc::clone(&state_store), - state_ttl_seconds, - http_timeout_seconds, - ) { - Ok(client) => client, - Err(error) => { - error!( - "Failed to create configured OAuth2 HTTP client; using default client: {}", - error - ); - Self { - http_transport: Arc::new(ReqwestOAuth2HttpTransport { - client: Client::new(), - }), - state_store, - state_ttl_seconds, - } - } - } + Self::try_new(state_store, state_ttl_seconds, http_timeout_seconds) + .expect("failed to build OAuth2 HTTP client") } pub fn try_new( @@ -224,6 +268,10 @@ impl OAuth2Client { additional_params: HashMap, binding: Option, ) -> OAuth2Result<(String, String)> { + // Reject reserved-parameter overrides before doing any work (H1). + reject_reserved_params(provider_config.auth_params.keys(), "provider auth_params")?; + reject_reserved_params(additional_params.keys(), "additional_params")?; + let mut url = Url::parse(&provider_config.authorization_endpoint)?; // Generate PKCE if enabled @@ -391,11 +439,21 @@ impl OAuth2Client { #[derive(serde::Deserialize)] struct IdTokenClaims { iss: Option, + sub: Option, aud: Option, + /// Authorized party — required to equal `client_id` when `aud` has multiple + /// entries (OIDC Core §3.1.3.7 / §2). + azp: Option, exp: Option, nonce: Option, } +/// Subject (`sub`) claim of an id_token, used to bind it to the userinfo +/// response so a confused-deputy userinfo cannot change the account (M6). +pub(crate) fn id_token_subject(id_token: &str) -> OAuth2Result> { + Ok(decode_id_token_claims(id_token)?.sub) +} + fn decode_id_token_claims(id_token: &str) -> OAuth2Result { let payload = id_token .split('.') @@ -421,24 +479,40 @@ pub(crate) fn validate_id_token_claims( ) -> OAuth2Result<()> { let claims = decode_id_token_claims(id_token)?; - if let Some(expected_issuer) = &provider_config.issuer - && claims.iss.as_deref() != Some(expected_issuer.as_str()) - { + // Issuer is fail-closed: an id_token whose issuer is unverified cannot be + // trusted to identify the account, so accepting one without a configured + // `issuer` is refused rather than silently skipped (M6). + let Some(expected_issuer) = &provider_config.issuer else { + return Err(OAuth2Error::InvalidIdToken( + "provider `issuer` must be configured to accept id_tokens".to_string(), + )); + }; + if claims.iss.as_deref() != Some(expected_issuer.as_str()) { return Err(OAuth2Error::InvalidIdToken(format!( "issuer mismatch: expected {expected_issuer}" ))); } + let client_id = provider_config.client_id.as_str(); let audience_matches = match &claims.aud { - Some(serde_json::Value::String(aud)) => aud == &provider_config.client_id, - Some(serde_json::Value::Array(auds)) => auds - .iter() - .any(|aud| aud.as_str() == Some(provider_config.client_id.as_str())), + Some(serde_json::Value::String(aud)) => aud == client_id, + Some(serde_json::Value::Array(auds)) => { + let contains = auds.iter().any(|aud| aud.as_str() == Some(client_id)); + if !contains { + false + } else if auds.len() > 1 { + // Multiple audiences: `azp` must be present and equal client_id. + claims.azp.as_deref() == Some(client_id) + } else { + true + } + } _ => false, }; if !audience_matches { return Err(OAuth2Error::InvalidIdToken( - "audience does not include this client".to_string(), + "audience does not include this client (or azp mismatch for multi-audience token)" + .to_string(), )); } @@ -457,6 +531,18 @@ pub(crate) fn validate_id_token_claims( return Err(OAuth2Error::InvalidIdToken("nonce mismatch".to_string())); } + // `sub` is REQUIRED by OIDC Core §2. Refuse an id_token without it so the + // userinfo <-> id_token subject binding (M6) cannot silently no-op on a + // token that carries no subject. + match claims.sub.as_deref() { + Some(sub) if !sub.trim().is_empty() => {} + _ => { + return Err(OAuth2Error::InvalidIdToken( + "id_token is missing the required `sub` claim".to_string(), + )); + } + } + Ok(()) } @@ -826,19 +912,49 @@ mod tests { let good = fake_id_token(serde_json::json!({ "iss": "https://issuer.test", + "sub": "subject-1", "aud": "test_client_id", "exp": exp, "nonce": "nonce-1", })); assert!(validate_id_token_claims(&config, &good, Some("nonce-1")).is_ok()); - // aud may be an array containing this client - let aud_array = fake_id_token(serde_json::json!({ + // An otherwise-valid id_token with no `sub` is rejected (M6): the + // userinfo binding must never run against an absent subject. + let no_sub = fake_id_token(serde_json::json!({ + "iss": "https://issuer.test", + "aud": "test_client_id", + "exp": exp, + "nonce": "nonce-1", + })); + assert!(validate_id_token_claims(&config, &no_sub, Some("nonce-1")).is_err()); + + // A single-element aud array containing this client is fine. + let aud_single_array = fake_id_token(serde_json::json!({ + "iss": "https://issuer.test", + "sub": "subject-1", + "aud": ["test_client_id"], + "exp": exp, + })); + assert!(validate_id_token_claims(&config, &aud_single_array, None).is_ok()); + + // Multi-audience token requires azp == client_id (M6). + let aud_array_with_azp = fake_id_token(serde_json::json!({ + "iss": "https://issuer.test", + "sub": "subject-1", + "aud": ["other", "test_client_id"], + "azp": "test_client_id", + "exp": exp, + })); + assert!(validate_id_token_claims(&config, &aud_array_with_azp, None).is_ok()); + + // Multi-audience token WITHOUT a matching azp is rejected. + let aud_array_no_azp = fake_id_token(serde_json::json!({ "iss": "https://issuer.test", "aud": ["other", "test_client_id"], "exp": exp, })); - assert!(validate_id_token_claims(&config, &aud_array, None).is_ok()); + assert!(validate_id_token_claims(&config, &aud_array_no_azp, None).is_err()); let bad_iss = fake_id_token(serde_json::json!({ "iss": "https://evil.test", "aud": "test_client_id", "exp": exp, @@ -871,6 +987,95 @@ mod tests { assert!(validate_id_token_claims(&config, "garbage", None).is_err()); } + #[test] + fn id_token_without_configured_issuer_is_rejected() { + // issuer is None on provider_config() -> fail closed (M6). + let config = provider_config(); + assert!(config.issuer.is_none()); + let exp = chrono::Utc::now().timestamp() + 600; + let token = fake_id_token(serde_json::json!({ + "iss": "https://issuer.test", + "aud": "test_client_id", + "exp": exp, + })); + assert!(matches!( + validate_id_token_claims(&config, &token, None), + Err(OAuth2Error::InvalidIdToken(_)) + )); + } + + #[test] + fn id_token_subject_extracts_sub() { + let token = fake_id_token(serde_json::json!({ "sub": "subject-123" })); + assert_eq!(id_token_subject(&token).unwrap().as_deref(), Some("subject-123")); + } + + #[tokio::test] + async fn reserved_params_cannot_override_security_parameters() { + let state_store = Arc::new(InMemoryStateStore::new()); + let client = OAuth2Client::new(state_store, 600, 30); + let config = provider_config(); + + for reserved in [ + "redirect_uri", + "response_type", + "state", + "client_id", + "code_challenge", + "nonce", + ] { + let mut params = HashMap::new(); + params.insert(reserved.to_string(), "attacker".to_string()); + let result = client.generate_authorization_url(&config, params).await; + assert!( + matches!(result, Err(OAuth2Error::InvalidAuthorizationParam(_))), + "expected `{reserved}` to be rejected, got {result:?}" + ); + } + + // Case-insensitive match is enforced too. + let mut params = HashMap::new(); + params.insert("Redirect_URI".to_string(), "https://evil.test/cb".to_string()); + assert!(matches!( + client.generate_authorization_url(&config, params).await, + Err(OAuth2Error::InvalidAuthorizationParam(_)) + )); + + // A safe extra parameter is still accepted and appears exactly once. + let mut params = HashMap::new(); + params.insert("login_hint".to_string(), "user@example.com".to_string()); + let (url, _) = client + .generate_authorization_url(&config, params) + .await + .unwrap(); + let parsed = Url::parse(&url).unwrap(); + assert_eq!( + parsed + .query_pairs() + .filter(|(k, _)| k == "redirect_uri") + .count(), + 1 + ); + assert!(url.contains("login_hint=user%40example.com")); + } + + #[tokio::test] + async fn reserved_params_in_provider_auth_params_are_rejected() { + let state_store = Arc::new(InMemoryStateStore::new()); + let client = OAuth2Client::new(state_store, 600, 30); + let mut config = provider_config(); + config + .auth_params + .insert("redirect_uri".to_string(), "https://evil.test/cb".to_string()); + + assert!(matches!( + client + .generate_authorization_url(&config, HashMap::new()) + .await, + Err(OAuth2Error::InvalidAuthorizationParam(_)) + )); + } + #[tokio::test] async fn handle_callback_enforces_session_binding() { let state_store = Arc::new(InMemoryStateStore::new()); diff --git a/crates/identity/ras-identity-oauth2/src/config.rs b/crates/identity/ras-identity-oauth2/src/config.rs index 982245f..36a3712 100644 --- a/crates/identity/ras-identity-oauth2/src/config.rs +++ b/crates/identity/ras-identity-oauth2/src/config.rs @@ -2,9 +2,10 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::fmt; /// OAuth2 provider configuration -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Clone, Serialize, Deserialize)] pub struct OAuth2ProviderConfig { pub provider_id: String, pub client_id: String, @@ -27,6 +28,26 @@ pub struct OAuth2ProviderConfig { pub user_info_mapping: Option, } +/// Manual `Debug` that redacts `client_secret` so it never lands in logs (L1). +impl fmt::Debug for OAuth2ProviderConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("OAuth2ProviderConfig") + .field("provider_id", &self.provider_id) + .field("client_id", &self.client_id) + .field("client_secret", &"[REDACTED]") + .field("authorization_endpoint", &self.authorization_endpoint) + .field("token_endpoint", &self.token_endpoint) + .field("userinfo_endpoint", &self.userinfo_endpoint) + .field("issuer", &self.issuer) + .field("redirect_uri", &self.redirect_uri) + .field("scopes", &self.scopes) + .field("auth_params", &self.auth_params) + .field("use_pkce", &self.use_pkce) + .field("user_info_mapping", &self.user_info_mapping) + .finish() + } +} + /// Mapping configuration for user info fields #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UserInfoMapping { @@ -147,6 +168,17 @@ mod tests { assert_eq!(parsed.subject_field, m.subject_field); } + #[test] + fn debug_redacts_client_secret() { + let mut p = provider(); + p.client_secret = "super-secret-value".into(); + let debug = format!("{p:?}"); + assert!(!debug.contains("super-secret-value")); + assert!(debug.contains("[REDACTED]")); + // Non-secret fields are still visible. + assert!(debug.contains("google")); + } + #[test] fn defaults_are_sensible() { let cfg = OAuth2Config::default(); diff --git a/crates/identity/ras-identity-oauth2/src/error.rs b/crates/identity/ras-identity-oauth2/src/error.rs index 1c4fa4c..b5936df 100644 --- a/crates/identity/ras-identity-oauth2/src/error.rs +++ b/crates/identity/ras-identity-oauth2/src/error.rs @@ -15,6 +15,9 @@ pub enum OAuth2Error { #[error("Invalid state parameter")] InvalidState, + #[error("Reserved OAuth parameter cannot be overridden: {0}")] + InvalidAuthorizationParam(String), + #[error("State not found or expired")] StateNotFound, diff --git a/crates/identity/ras-identity-oauth2/src/provider.rs b/crates/identity/ras-identity-oauth2/src/provider.rs index a056c8d..796c20c 100644 --- a/crates/identity/ras-identity-oauth2/src/provider.rs +++ b/crates/identity/ras-identity-oauth2/src/provider.rs @@ -39,8 +39,17 @@ pub enum OAuth2AuthPayload { #[derive(Debug, Serialize, Deserialize)] #[serde(tag = "type")] pub enum OAuth2Response { - /// Authorization URL to redirect the user to - AuthorizationUrl { url: String, state: String }, + /// Authorization URL to redirect the user to. + /// + /// `binding` is the login-CSRF binding for this flow: the integrator must + /// store it (e.g. in a cookie) and echo it back on the callback payload. + /// `start_flow` always populates it; it is `None` only for the explicit + /// unbound escape hatch (`start_flow_bound(.., None)`). + AuthorizationUrl { + url: String, + state: String, + binding: Option, + }, /// Error response Error { message: String }, } @@ -110,24 +119,35 @@ impl OAuth2Provider { /// Start an OAuth2 authorization flow. /// - /// Returns the authorization URL to redirect the user to, plus the - /// `state` parameter bound to this flow. This is the supported way to - /// initiate a flow; `verify()` only completes one (the `Callback` - /// payload). + /// Returns the authorization URL to redirect the user to, the `state` + /// parameter, and a login-CSRF `binding` that this method generates for + /// you. The integrator must store the binding (e.g. in a cookie) and echo + /// it on the callback payload; a callback without it is rejected. This is + /// the supported way to initiate a flow; `verify()` only completes one. + /// + /// Use [`Self::start_flow_bound`] only if you want to supply your own + /// binding value or explicitly opt out of binding. pub async fn start_flow( &self, provider_id: &str, additional_params: Option>, ) -> OAuth2Result { - self.start_flow_bound(provider_id, additional_params, None) + // Always bind by default: an unbound flow lets an attacker start a flow + // and trick a victim into completing it, joining the attacker's app + // session to the victim's IdP identity (M2). + let binding = uuid::Uuid::new_v4().to_string(); + self.start_flow_bound(provider_id, additional_params, Some(binding)) .await } - /// Start a flow bound to the initiating browser session. + /// Start a flow with an explicit (or absent) session binding. /// /// `binding` should be an unguessable value the integrator can recover on /// callback (e.g. a random cookie value); the callback payload must then /// carry the identical value or it is rejected, preventing login CSRF. + /// Passing `None` opts out of binding — only do this for non-browser flows + /// where login CSRF does not apply. Prefer [`Self::start_flow`], which + /// generates a binding for you. pub async fn start_flow_bound( &self, provider_id: &str, @@ -139,14 +159,16 @@ impl OAuth2Provider { let (auth_url, state) = self .client - .generate_authorization_url_bound(provider_config, params, binding) + .generate_authorization_url_bound(provider_config, params, binding.clone()) .await?; info!("Started OAuth2 flow for provider: {}", provider_id); + // Echo the binding back so the integrator can set the matching cookie. Ok(OAuth2Response::AuthorizationUrl { url: auth_url, state, + binding, }) } @@ -182,6 +204,26 @@ impl OAuth2Provider { .get_user_info(provider_config, &token_response.access_token) .await?; + // Bind the userinfo response to the id_token: identity is derived from + // userinfo, so a wrong/confused userinfo endpoint must not be able to + // change the account when an id_token established the subject (M6). + // Fail closed if the id_token carries no `sub` (validate_id_token_claims + // already requires it, but this must never silently pass). When a custom + // `subject_field` is configured the resolved identity subject is a + // userinfo claim trusted transitively via this `sub` binding. + if let Some(id_token) = &token_response.id_token { + let id_sub = crate::client::id_token_subject(id_token)?.ok_or_else(|| { + OAuth2Error::InvalidIdToken( + "id_token is missing the required `sub` claim".to_string(), + ) + })?; + if id_sub != user_info.sub { + return Err(OAuth2Error::InvalidIdToken( + "userinfo subject does not match id_token subject".to_string(), + )); + } + } + // Map user info to VerifiedIdentity let verified_identity = self.map_user_info_to_identity(provider_id, user_info, provider_config)?; @@ -354,11 +396,17 @@ mod tests { let result = provider.start_flow("google", None).await.unwrap(); match result { - OAuth2Response::AuthorizationUrl { url, state } => { + OAuth2Response::AuthorizationUrl { + url, + state, + binding, + } => { assert!(url.contains("https://accounts.google.com/o/oauth2/v2/auth")); assert!(url.contains("response_type=code")); assert!(url.contains("client_id=test_client_id")); assert!(!state.is_empty()); + // Default start_flow always binds (M2). + assert!(binding.is_some_and(|b| !b.is_empty())); } _ => panic!("Expected AuthorizationUrl response"), } @@ -418,11 +466,121 @@ mod tests { .await .expect("start_flow succeeds"); - let OAuth2Response::AuthorizationUrl { url, state } = response else { + let OAuth2Response::AuthorizationUrl { + url, + state, + binding, + } = response + else { panic!("expected authorization URL response"); }; assert!(url.contains("prompt=consent")); assert!(!state.is_empty()); + assert!(binding.is_some()); + } + + fn fake_id_token(payload: serde_json::Value) -> String { + use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; + let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"RS256","typ":"JWT"}"#); + let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap()); + format!("{header}.{payload}.signature") + } + + struct FixedTransport { + id_token: Option, + userinfo_sub: String, + } + + #[async_trait] + impl crate::client::OAuth2HttpTransport for FixedTransport { + async fn exchange_code( + &self, + _token_endpoint: &str, + _params: &HashMap, + ) -> OAuth2Result { + Ok(crate::types::TokenResponse { + access_token: "access-token".to_string(), + token_type: "Bearer".to_string(), + expires_in: Some(3600), + refresh_token: None, + scope: None, + id_token: self.id_token.clone(), + }) + } + + async fn get_user_info( + &self, + _userinfo_endpoint: &str, + _access_token: &str, + ) -> OAuth2Result { + Ok(crate::types::UserInfoResponse { + sub: self.userinfo_sub.clone(), + email: None, + email_verified: None, + name: None, + given_name: None, + family_name: None, + picture: None, + locale: None, + additional_claims: HashMap::new(), + }) + } + } + + #[tokio::test] + async fn callback_rejects_userinfo_subject_mismatch_with_id_token() { + use crate::state::{OAuth2State, OAuth2StateStore}; + + let mut config = google_config(); + config.issuer = Some("https://issuer.test".to_string()); + let exp = chrono::Utc::now().timestamp() + 600; + // Build an id_token that passes iss/aud/exp/nonce so validation reaches + // the subject cross-check. It carries sub = "id-token-subject". + let id_token = fake_id_token(serde_json::json!({ + "iss": "https://issuer.test", + "aud": config.client_id, + "sub": "id-token-subject", + "exp": exp, + "nonce": "nonce-abc", + })); + + // userinfo returns a DIFFERENT subject than the id_token (confused deputy). + let transport = Arc::new(FixedTransport { + id_token: Some(id_token), + userinfo_sub: "userinfo-subject".to_string(), + }); + let state_store = Arc::new(InMemoryStateStore::new()); + let client = crate::client::OAuth2Client::with_http_transport( + state_store.clone(), + 600, + transport, + ); + let mut providers = HashMap::new(); + providers.insert("google".to_string(), config.clone()); + let provider = OAuth2Provider::with_client(providers, client); + + // Store a flow state with a known nonce + binding so the id_token matches. + let state = OAuth2State::new("google".to_string(), config.redirect_uri.clone(), None, 600) + .with_nonce("nonce-abc".to_string()) + .with_binding(Some("binding-xyz".to_string())); + let state_param = state.state.clone(); + state_store.store(state).await.unwrap(); + + let result = provider + .handle_callback( + "google", + "code".to_string(), + state_param, + None, + None, + Some("binding-xyz".to_string()), + ) + .await; + + assert!( + matches!(result, Err(OAuth2Error::InvalidIdToken(_))), + "subject mismatch must be rejected, got {result:?}" + ); } #[test] diff --git a/crates/identity/ras-identity-oauth2/src/tests.rs b/crates/identity/ras-identity-oauth2/src/tests.rs index adffb5f..26c0254 100644 --- a/crates/identity/ras-identity-oauth2/src/tests.rs +++ b/crates/identity/ras-identity-oauth2/src/tests.rs @@ -223,12 +223,18 @@ mod integration_tests { // Start OAuth2 flow via the typed API let start_result = provider.start_flow("mock_provider", None).await.unwrap(); - let auth_url = match start_result { - OAuth2Response::AuthorizationUrl { url, state } => { + let (auth_url, binding) = match start_result { + OAuth2Response::AuthorizationUrl { + url, + state, + binding, + } => { assert!(url.contains("/authorize")); assert!(url.contains("response_type=code")); assert!(url.contains("code_challenge")); - state + // start_flow binds by default (M2). + assert!(binding.is_some()); + (state, binding) } _ => panic!("Expected authorization URL"), }; @@ -243,12 +249,13 @@ mod integration_tests { Err(ras_identity_core::IdentityError::UnsupportedMethod) )); - // Simulate callback + // Simulate callback — echo the binding captured at start (M2). let callback_payload = serde_json::json!({ "type": "Callback", "provider_id": "mock_provider", "code": "mock_auth_code", - "state": auth_url + "state": auth_url, + "binding": binding }); let callback_result = provider.verify(callback_payload).await; diff --git a/crates/identity/ras-identity-session/Cargo.toml b/crates/identity/ras-identity-session/Cargo.toml index 82bf5db..6a7b383 100644 --- a/crates/identity/ras-identity-session/Cargo.toml +++ b/crates/identity/ras-identity-session/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ras-identity-session" -version = "0.2.0" +version = "0.3.0" edition = "2024" rust-version = "1.88" description = "JWT session management and authentication provider implementation" @@ -11,7 +11,7 @@ readme = "README.md" [dependencies] ras-identity-core = { path = "../../core/ras-identity-core", version = "0.1.1" } -ras-auth-core = { path = "../../core/ras-auth-core", version = "0.1.0" } +ras-auth-core = { path = "../../core/ras-auth-core", version = "0.2.0" } async-trait = { workspace = true } base64 = { workspace = true } @@ -25,4 +25,4 @@ tokio = { workspace = true } uuid = { workspace = true } [dev-dependencies] -ras-identity-local = { path = "../ras-identity-local", version = "0.2.0" } +ras-identity-local = { path = "../ras-identity-local", version = "0.2.1" } diff --git a/crates/identity/ras-identity-session/README.md b/crates/identity/ras-identity-session/README.md index 28bb200..8836d1e 100644 --- a/crates/identity/ras-identity-session/README.md +++ b/crates/identity/ras-identity-session/README.md @@ -35,9 +35,10 @@ provider let session_service = Arc::new(SessionService::new(SessionConfig { jwt_secret: "use-at-least-32-bytes-of-random-secret".to_string(), jwt_ttl: Duration::hours(1), - refresh_enabled: false, enforce_active_sessions: true, algorithm: JwtAlgorithm::HS256, + iss: None, + aud: None, })?); session_service.register_provider(Box::new(provider)).await; diff --git a/crates/identity/ras-identity-session/src/lib.rs b/crates/identity/ras-identity-session/src/lib.rs index e883f65..5c26417 100644 --- a/crates/identity/ras-identity-session/src/lib.rs +++ b/crates/identity/ras-identity-session/src/lib.rs @@ -47,6 +47,10 @@ pub struct JwtClaims { pub display_name: Option, pub permissions: HashSet, pub metadata: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub iss: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub aud: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -70,13 +74,41 @@ impl JwtAlgorithm { } } -#[derive(Debug, Clone)] +/// Session/JWT configuration. +/// +/// Permission semantics: the permissions granted at [`SessionService::begin_session`] +/// are frozen into the JWT and are **not** reloaded on verify. With the default +/// `enforce_active_sessions: true`, revoking a session (`end_session`) takes +/// effect immediately per-`jti`; otherwise grants are fixed for `jwt_ttl` +/// (default 24h). Set `iss`/`aud` in production so tokens minted for one service +/// are not accepted by another sharing the same secret. +#[derive(Clone)] pub struct SessionConfig { pub jwt_secret: String, pub jwt_ttl: Duration, - pub refresh_enabled: bool, pub enforce_active_sessions: bool, pub algorithm: JwtAlgorithm, + /// Expected token issuer. When `Some`, it is encoded into new tokens and + /// verified on `verify_session`; a mismatch is rejected. + pub iss: Option, + /// Expected token audience. When `Some`, it is encoded into new tokens and + /// verified on `verify_session`; a token for a different `aud` is rejected. + /// This is the cross-service confused-deputy guard (M3). + pub aud: Option, +} + +/// Redacting `Debug` so `jwt_secret` never lands in logs (L1). +impl std::fmt::Debug for SessionConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SessionConfig") + .field("jwt_secret", &"[REDACTED]") + .field("jwt_ttl", &self.jwt_ttl) + .field("enforce_active_sessions", &self.enforce_active_sessions) + .field("algorithm", &self.algorithm) + .field("iss", &self.iss) + .field("aud", &self.aud) + .finish() + } } impl SessionConfig { @@ -84,14 +116,28 @@ impl SessionConfig { let config = Self { jwt_secret: jwt_secret.into(), jwt_ttl: Duration::hours(24), - refresh_enabled: true, enforce_active_sessions: true, algorithm: JwtAlgorithm::HS256, + iss: None, + aud: None, }; config.validate()?; Ok(config) } + /// Set the expected issuer (`iss`). Production services should set this. + pub fn with_issuer(mut self, issuer: impl Into) -> Self { + self.iss = Some(issuer.into()); + self + } + + /// Set the expected audience (`aud`). Production services should set this so + /// a token minted for another service is rejected here. + pub fn with_audience(mut self, audience: impl Into) -> Self { + self.aud = Some(audience.into()); + self + } + pub fn validate(&self) -> Result<(), SessionError> { validate_jwt_secret(&self.jwt_secret)?; @@ -348,6 +394,8 @@ impl SessionService { display_name: identity.display_name.clone(), permissions: permissions.into_iter().collect(), metadata: identity.metadata, + iss: self.config.iss.clone(), + aud: self.config.aud.clone(), }; if self.config.enforce_active_sessions { @@ -372,6 +420,19 @@ impl SessionService { return Err(SessionError::TokenExpired); } + // Cross-service confused-deputy guard: reject tokens minted for a + // different issuer/audience when this service configures them (M3). + if let Some(expected_iss) = &self.config.iss + && claims.iss.as_deref() != Some(expected_iss.as_str()) + { + return Err(SessionError::InvalidSession); + } + if let Some(expected_aud) = &self.config.aud + && claims.aud.as_deref() != Some(expected_aud.as_str()) + { + return Err(SessionError::InvalidSession); + } + if self.config.enforce_active_sessions { let sessions = self.active_sessions.read().await; if !sessions.contains_key(&claims.jti) { @@ -570,6 +631,80 @@ mod tests { assert!(matches!(result, Err(SessionError::InvalidConfig(_)))); } + #[test] + fn debug_redacts_jwt_secret() { + let config = SessionConfig::new(TEST_SECRET).unwrap(); + let debug = format!("{config:?}"); + assert!(!debug.contains(TEST_SECRET)); + assert!(debug.contains("[REDACTED]")); + } + + #[tokio::test] + async fn token_for_one_audience_is_rejected_by_another_service() { + // Two services share a secret but configure different audiences (M3). + let service_a = SessionService::new( + SessionConfig::new(TEST_SECRET).unwrap().with_audience("svc-a"), + ) + .unwrap(); + let local = LocalUserProvider::new(); + local + .add_user("u".to_string(), "password123".to_string(), None, None) + .await + .unwrap(); + service_a.register_provider(Box::new(local)).await; + + let token = service_a + .begin_session( + "local", + serde_json::json!({"username": "u", "password": "password123"}), + ) + .await + .unwrap(); + + // A service configured for a different audience rejects the token + // (the aud check runs before the active-session check). + let service_b = SessionService::new( + SessionConfig::new(TEST_SECRET).unwrap().with_audience("svc-b"), + ) + .unwrap(); + assert!(matches!( + service_b.verify_session(&token).await, + Err(SessionError::InvalidSession) + )); + + // The issuing service (correct audience) still accepts it. + assert!(service_a.verify_session(&token).await.is_ok()); + } + + #[tokio::test] + async fn permissions_are_frozen_into_the_token_snapshot() { + // Names the documented freeze behavior (M3): the permission set is + // copied into the JWT at begin_session and returned verbatim on verify; + // it is not reloaded. If a reload is ever added, this test must change. + let permissions_provider = + Arc::new(StaticPermissions::new(vec!["read".to_string()])); + let service = SessionService::new(SessionConfig::new(TEST_SECRET).unwrap()) + .unwrap() + .with_permissions(permissions_provider); + let local = LocalUserProvider::new(); + local + .add_user("u".to_string(), "password123".to_string(), None, None) + .await + .unwrap(); + service.register_provider(Box::new(local)).await; + + let token = service + .begin_session( + "local", + serde_json::json!({"username": "u", "password": "password123"}), + ) + .await + .unwrap(); + let claims = service.verify_session(&token).await.unwrap(); + assert_eq!(claims.permissions.len(), 1); + assert!(claims.permissions.contains("read")); + } + #[tokio::test] async fn test_cleanup_expired_sessions() { let config = SessionConfig::new(TEST_SECRET).unwrap(); @@ -589,6 +724,8 @@ mod tests { display_name: None, permissions: HashSet::new(), metadata: None, + iss: None, + aud: None, }, ); } @@ -732,6 +869,8 @@ mod tests { display_name: None, permissions: HashSet::new(), metadata: None, + iss: None, + aud: None, }, ); assert_eq!(service.active_session_count().await, 1); diff --git a/crates/observability/ras-observability-otel/Cargo.toml b/crates/observability/ras-observability-otel/Cargo.toml index c886cc2..75b77b5 100644 --- a/crates/observability/ras-observability-otel/Cargo.toml +++ b/crates/observability/ras-observability-otel/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ras-observability-otel" -version = "0.1.0" +version = "0.2.0" edition = "2024" rust-version = "1.88" description = "OpenTelemetry implementation for Rust Agent Stack observability" @@ -10,8 +10,8 @@ homepage = "https://github.com/JedimEmO/rust-api-stack" readme = "README.md" [dependencies] -ras-observability-core = { path = "../../core/ras-observability-core", version = "0.1.0" } -ras-auth-core = { path = "../../core/ras-auth-core", version = "0.1.0" } +ras-observability-core = { path = "../../core/ras-observability-core", version = "0.2.0" } +ras-auth-core = { path = "../../core/ras-auth-core", version = "0.2.0" } # OpenTelemetry dependencies opentelemetry = { workspace = true } diff --git a/crates/rest/ras-file-core/Cargo.toml b/crates/rest/ras-file-core/Cargo.toml index 9030bc0..610de43 100644 --- a/crates/rest/ras-file-core/Cargo.toml +++ b/crates/rest/ras-file-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ras-file-core" -version = "0.1.0" +version = "0.2.0" edition = "2024" rust-version = "1.88" description = "Core runtime types for Rust Agent Stack file upload and download services" @@ -14,5 +14,5 @@ bytes = { workspace = true } futures-core = { workspace = true } futures-util = { workspace = true } http = { workspace = true } -ras-auth-core = { path = "../../core/ras-auth-core", version = "0.1.0" } +ras-auth-core = { path = "../../core/ras-auth-core", version = "0.2.0" } thiserror = { workspace = true } diff --git a/crates/rest/ras-file-macro/Cargo.toml b/crates/rest/ras-file-macro/Cargo.toml index 223cf35..e50d1d9 100644 --- a/crates/rest/ras-file-macro/Cargo.toml +++ b/crates/rest/ras-file-macro/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ras-file-macro" -version = "0.1.0" +version = "0.2.0" edition = "2024" rust-version = "1.88" description = "Procedural macro for type-safe file upload and download APIs" @@ -40,8 +40,8 @@ futures-util = { workspace = true } axum = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -ras-auth-core = { path = "../../core/ras-auth-core", version = "0.1.0" } -ras-file-core = { path = "../ras-file-core", version = "0.1.0" } +ras-auth-core = { path = "../../core/ras-auth-core", version = "0.2.0" } +ras-file-core = { path = "../ras-file-core", version = "0.2.0" } ras-permission-manifest = { path = "../../specs/ras-permission-manifest", version = "0.1.0" } # Generated-client tests drive the client over the in-process AxumTestTransport, # and the default `build()` path needs the reqwest transport to compile. diff --git a/crates/rest/ras-file-macro/src/parser.rs b/crates/rest/ras-file-macro/src/parser.rs index 21d61a1..d84d39b 100644 --- a/crates/rest/ras-file-macro/src/parser.rs +++ b/crates/rest/ras-file-macro/src/parser.rs @@ -487,6 +487,18 @@ fn parse_auth(input: ParseStream) -> Result { )); } + if permission_groups.len() > 1 + && permission_groups.iter().any(|group| group.is_empty()) + { + return Err(Error::new( + auth_ident.span(), + "an empty permission group is only valid as the entire requirement \ + (WITH_PERMISSIONS([]), meaning any authenticated user); mixing an empty \ + group with non-empty groups would silently grant access to any authenticated \ + user", + )); + } + Ok(AuthRequirement::WithPermissions(permission_groups)) } _ => Err(Error::new( diff --git a/crates/rest/ras-file-macro/src/server.rs b/crates/rest/ras-file-macro/src/server.rs index 689dae9..7b2261a 100644 --- a/crates/rest/ras-file-macro/src/server.rs +++ b/crates/rest/ras-file-macro/src/server.rs @@ -59,6 +59,9 @@ pub fn generate_server(definition: &FileServiceDefinition) -> TokenStream { pub fn auth_cookie(mut self, cookie: ::ras_auth_core::AuthCookieConfig) -> Self { self.auth_transport.cookie = Some(cookie); + if self.auth_transport.csrf.is_none() { + self.auth_transport.csrf = Some(::ras_auth_core::CsrfConfig::default()); + } self } diff --git a/crates/rest/ras-rest-core/Cargo.toml b/crates/rest/ras-rest-core/Cargo.toml index 35d5211..926b78f 100644 --- a/crates/rest/ras-rest-core/Cargo.toml +++ b/crates/rest/ras-rest-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ras-rest-core" -version = "0.1.1" +version = "0.2.0" edition = "2024" rust-version = "1.88" description = "Core types and traits for REST services in Rust Agent Stack" @@ -12,5 +12,6 @@ readme = "README.md" [dependencies] serde = { workspace = true } thiserror = { workspace = true } -ras-auth-core = { path = "../../core/ras-auth-core", version = "0.1.0" } +tracing = { workspace = true } +ras-auth-core = { path = "../../core/ras-auth-core", version = "0.2.0" } ras-version-core = { path = "../../core/ras-version-core", version = "0.1.0" } diff --git a/crates/rest/ras-rest-core/src/lib.rs b/crates/rest/ras-rest-core/src/lib.rs index 40ce2d1..9fa7f7c 100644 --- a/crates/rest/ras-rest-core/src/lib.rs +++ b/crates/rest/ras-rest-core/src/lib.rs @@ -10,6 +10,10 @@ use thiserror::Error; pub use ras_auth_core::{AuthError, AuthProvider, AuthResult, AuthenticatedUser}; pub use ras_version_core::*; +// Re-export `tracing` so generated REST server code can log without requiring +// every consumer crate to declare a direct `tracing` dependency. +pub use tracing; + /// Result type for REST handlers that allows explicit HTTP status codes. pub type RestResult = Result, RestError>; @@ -38,7 +42,11 @@ impl RestResponse { Self { status: 202, body } } - /// Create a 204 No Content response (requires T to be ()). + /// Create a 204 No Content response. The body is `T::default()`, but the + /// generated server omits it on the wire (204 carries no body per RFC 9110); + /// prefer `T = ()`. A generated client for such an endpoint deserializes the + /// empty body as `null`, so a non-unit response type should be `Option<_>` or + /// `serde_json::Value` rather than a plain struct. pub fn no_content() -> Self where T: Default, diff --git a/crates/rest/ras-rest-macro/Cargo.toml b/crates/rest/ras-rest-macro/Cargo.toml index 8d6ab4e..4065eac 100644 --- a/crates/rest/ras-rest-macro/Cargo.toml +++ b/crates/rest/ras-rest-macro/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ras-rest-macro" -version = "0.2.1" +version = "0.3.0" edition = "2024" rust-version = "1.88" description = "Procedural macro for type-safe REST APIs with auth integration and OpenAPI document generation" @@ -28,8 +28,8 @@ schemars = { workspace = true } # Server dependencies axum = { workspace = true, optional = true } -ras-auth-core = { path = "../../core/ras-auth-core", version = "0.1.0", optional = true } -ras-rest-core = { path = "../ras-rest-core", version = "0.1.1", optional = true } +ras-auth-core = { path = "../../core/ras-auth-core", version = "0.2.0", optional = true } +ras-rest-core = { path = "../ras-rest-core", version = "0.2.0", optional = true } async-trait = { workspace = true, optional = true } # Client dependencies @@ -41,8 +41,8 @@ bytes = { workspace = true } ras-transport-core = { path = "../../core/ras-transport-core", version = "0.1.0", features = ["axum-test"] } tower = { workspace = true } rand = { workspace = true } -ras-identity-session = { path = "../../identity/ras-identity-session", version = "0.2.0" } -ras-jsonrpc-core = { path = "../../rpc/ras-jsonrpc-core", version = "0.1.2" } +ras-identity-session = { path = "../../identity/ras-identity-session", version = "0.3.0" } +ras-jsonrpc-core = { path = "../../rpc/ras-jsonrpc-core", version = "0.2.0" } futures = { workspace = true } chrono = { workspace = true } serde_json = { workspace = true } @@ -51,8 +51,8 @@ async-trait = { workspace = true } # Server dependencies for tests axum = { workspace = true } axum-extra = { workspace = true } -ras-auth-core = { path = "../../core/ras-auth-core", version = "0.1.0" } -ras-rest-core = { path = "../ras-rest-core", version = "0.1.1" } +ras-auth-core = { path = "../../core/ras-auth-core", version = "0.2.0" } +ras-rest-core = { path = "../ras-rest-core", version = "0.2.0" } ras-permission-manifest = { path = "../../specs/ras-permission-manifest", version = "0.1.0" } axum-test = { workspace = true } schemars = { workspace = true } diff --git a/crates/rest/ras-rest-macro/src/client.rs b/crates/rest/ras-rest-macro/src/client.rs index 88adee8..9332234 100644 --- a/crates/rest/ras-rest-macro/src/client.rs +++ b/crates/rest/ras-rest-macro/src/client.rs @@ -440,7 +440,15 @@ fn generate_client_method_with_timeout( let __response = self.transport.execute(__request).await?; let __response = __response.error_for_status().await?; let __bytes = __response.bytes().await?; - let __result = ::ras_transport_core::deserialize_json(&__bytes)?; + // A 204/304 (or otherwise empty) success body deserializes as JSON + // `null` so `Option` / `serde_json::Value` responses resolve to + // `None` / `Null` instead of failing with an EOF error. (The server + // now emits an empty body for 204/304 per RFC 9110.) + let __result = if __bytes.is_empty() { + ::ras_transport_core::deserialize_json(b"null")? + } else { + ::ras_transport_core::deserialize_json(&__bytes)? + }; Ok(__result) } }; diff --git a/crates/rest/ras-rest-macro/src/lib.rs b/crates/rest/ras-rest-macro/src/lib.rs index 8fd98fe..1a26716 100644 --- a/crates/rest/ras-rest-macro/src/lib.rs +++ b/crates/rest/ras-rest-macro/src/lib.rs @@ -30,6 +30,57 @@ mod static_hosting; /// * `WITH_PERMISSIONS([...])` — authenticated and gated; a missing or /// insufficient credential is rejected before the handler runs. /// +/// # Request bodies and `Content-Type` +/// +/// Endpoints that declare a body type read and JSON-decode it only **after** the +/// auth/CSRF/permission checks pass, so unauthenticated callers cannot make the +/// server buffer or parse payloads. By default a request whose `Content-Type` is +/// not `application/json` (ignoring parameters such as `; charset=utf-8`) is +/// rejected with `415 Unsupported Media Type` before the body is read. This is +/// defense-in-depth: requiring `application/json` forces a CORS preflight for +/// cross-origin requests, closing the simple-request CSRF shape (a cross-origin +/// `text/plain` POST). Set `require_json_content_type: false` at the service +/// level to accept any content type (e.g. for clients that cannot set the +/// header). A malformed body is logged (category + line/column, never the +/// rejected value) and answered with `400`; a body over the size limit is `413`, +/// distinct from an unreadable stream (`400`). +/// +/// # Service options +/// +/// * `body_limit: ` — maximum request body size (default 2 MiB). +/// * `require_json_content_type: ` — enforce `application/json` on bodied +/// endpoints (default `true`). +/// * `serve_docs: ` / `docs_path: "..."` — host the API explorer and +/// `openapi.json`. +/// * `docs_require_auth: ` — when `serve_docs` is enabled, gate the docs +/// page and `openapi.json` behind authentication (any authenticated user). +/// Default `false`: docs are public, matching conventional API explorers. +/// * `feature_gated: ` — wrap the generated server/client behind the +/// consumer crate's own `server`/`client` features. +/// +/// # Per-endpoint options +/// +/// A trailing `{ ... }` block after the response type accepts: +/// +/// * `body_limit: ` — override the service body limit for this endpoint. +/// * `headers: true` — pass the request [`axum::http::HeaderMap`] to the handler +/// as an extra parameter, immediately after the caller/user and before the +/// path parameters. The map is unredacted (it contains the caller's +/// `Authorization`/`Cookie`/CSRF headers), so must not be logged or forwarded +/// verbatim; redact with +/// `ras_auth_core::redact_sensitive_headers_for_auth_transport` first. +/// * `version: "..."` / `versions: [ ... ]` — see Versioning. +/// +/// # Versioning +/// +/// An endpoint may serve older payload shapes at legacy paths and migrate them +/// to the canonical request/response types. Provide a canonical `version:` label +/// and one or more `versions: [ "vN" { path: ..., request: T, response: U, +/// migration: M }, ... ]` entries, where `M` implements +/// [`ras_rest_core::VersionMigration`] for both the request (legacy → canonical) +/// and the response (canonical → legacy). Each legacy path is registered as its +/// own route sharing the endpoint's auth level. +/// /// # Example /// /// ```rust @@ -95,6 +146,14 @@ struct ServiceDefinition { static_hosting: static_hosting::StaticHostingConfig, body_limit: Option, feature_gated: bool, + /// Require an `application/json` request `Content-Type` on every endpoint + /// that declares a body. Defaults to `true`. Set `require_json_content_type: + /// false` to opt out (e.g. for clients that cannot set the header). + require_json_content_type: bool, + /// Gate the generated docs page and `openapi.json` behind authentication + /// (any authenticated user). Defaults to `false` — docs are public when + /// `serve_docs` is enabled, matching conventional API-explorer behavior. + docs_require_auth: bool, endpoints: Vec, } @@ -120,6 +179,12 @@ struct EndpointDefinition { handler_name: Ident, version: Option, versions: Vec, + /// Per-endpoint request body size cap (bytes). Overrides the service-level + /// `body_limit` for this endpoint when set. + body_limit: Option, + /// When `true`, the handler receives the request `HeaderMap` as an extra + /// parameter (immediately after the caller/user, before path params). + with_headers: bool, } #[derive(Debug)] @@ -272,6 +337,8 @@ impl Parse for ServiceDefinition { let mut static_hosting = static_hosting::StaticHostingConfig::default(); let mut body_limit = None; let mut feature_gated = false; + let mut require_json_content_type = true; + let mut docs_require_auth = false; // Parse optional fields while content.peek(Ident) { @@ -329,6 +396,18 @@ impl Parse for ServiceDefinition { let enabled = content.parse::()?; feature_gated = enabled.value(); let _ = content.parse::()?; + } else if field_name == "require_json_content_type" { + let _ = content.parse::()?; // "require_json_content_type" + let _ = content.parse::()?; + let enabled = content.parse::()?; + require_json_content_type = enabled.value(); + let _ = content.parse::()?; + } else if field_name == "docs_require_auth" { + let _ = content.parse::()?; // "docs_require_auth" + let _ = content.parse::()?; + let enabled = content.parse::()?; + docs_require_auth = enabled.value(); + let _ = content.parse::()?; } else if field_name == "endpoints" { break; // Start parsing endpoints } else { @@ -364,6 +443,8 @@ impl Parse for ServiceDefinition { static_hosting, body_limit, feature_gated, + require_json_content_type, + docs_require_auth, endpoints, }) } @@ -516,6 +597,18 @@ impl Parse for EndpointDefinition { permission_groups.push(group); } + if permission_groups.len() > 1 + && permission_groups.iter().any(|group| group.is_empty()) + { + return Err(syn::Error::new( + auth_ident.span(), + "an empty permission group is only valid as the entire requirement \ + (WITH_PERMISSIONS([]), meaning any authenticated user); mixing an \ + empty group with non-empty groups would silently grant access to any \ + authenticated user", + )); + } + AuthRequirement::WithPermissions(permission_groups) } _ => { @@ -566,6 +659,8 @@ impl Parse for EndpointDefinition { let mut version = None; let mut versions = Vec::new(); + let mut body_limit = None; + let mut with_headers = false; if input.peek(syn::token::Brace) { let content; @@ -591,10 +686,17 @@ impl Parse for EndpointDefinition { } } } + "body_limit" => { + let limit = content.parse::()?; + body_limit = Some(limit.base10_parse::()?); + } + "headers" => { + with_headers = content.parse::()?.value(); + } _ => { return Err(syn::Error::new( field_name.span(), - "Expected version or versions", + "Expected version, versions, body_limit, or headers", )); } } @@ -617,6 +719,8 @@ impl Parse for EndpointDefinition { handler_name, version, versions, + body_limit, + with_headers, }) } } @@ -744,6 +848,11 @@ fn generate_service_code(service_def: ServiceDefinition) -> syn::Result syn::Result syn::Result syn::Result syn::Result axum::response::Response { use axum::response::IntoResponse; - let (status, message) = match error { + let (status, message) = match &error { ras_auth_core::AuthorizeError::MissingCredential => ( axum::http::StatusCode::UNAUTHORIZED, "Missing or invalid Authorization header", @@ -865,9 +1000,41 @@ fn generate_service_code(service_def: ServiceDefinition) -> syn::Result( + status: axum::http::StatusCode, + body: T, + ) -> axum::response::Response { + use axum::response::IntoResponse; + if status == axum::http::StatusCode::NO_CONTENT + || status == axum::http::StatusCode::RESET_CONTENT + || status == axum::http::StatusCode::NOT_MODIFIED + { + status.into_response() + } else { + (status, axum::Json(body)).into_response() + } + } + /// Generated service trait #[async_trait::async_trait] #[allow(private_interfaces, private_bounds)] @@ -924,8 +1091,15 @@ fn generate_service_code(service_def: ServiceDefinition) -> syn::Result Self { self.auth_transport.cookie = Some(cookie); + if self.auth_transport.csrf.is_none() { + self.auth_transport.csrf = Some(ras_auth_core::CsrfConfig::default()); + } self } @@ -973,6 +1147,8 @@ fn generate_service_code(service_def: ServiceDefinition) -> syn::Result proc_macro2::TokenStream { let method_routing = endpoint.method.as_axum_method(); let path = &endpoint.path; @@ -1265,7 +1442,7 @@ fn generate_canonical_route_registration( endpoint.request_type.as_ref(), query_struct_name, ); - let handler_body = generate_handler_body(endpoint, handler_name, method_str, path); + let handler_body = generate_handler_body(endpoint, handler_name, method_str, path, require_json); let permission_groups_code = rest_permission_groups_code(&endpoint.auth); quote! { @@ -1300,6 +1477,7 @@ fn generate_legacy_route_registration( endpoint: &EndpointDefinition, version: &EndpointVersionDefinition, query_struct_name: &Ident, + require_json: bool, ) -> proc_macro2::TokenStream { let method_routing = endpoint.method.as_axum_method(); let path = &version.path; @@ -1309,7 +1487,7 @@ fn generate_legacy_route_registration( version.request_type.as_ref(), query_struct_name, ); - let handler_body = generate_legacy_handler_body(service_name, endpoint, version); + let handler_body = generate_legacy_handler_body(service_name, endpoint, version, require_json); let permission_groups_code = rest_permission_groups_code(&endpoint.auth); quote! { @@ -1343,10 +1521,12 @@ fn generate_legacy_handler_body( service_name: &Ident, endpoint: &EndpointDefinition, version: &EndpointVersionDefinition, + require_json: bool, ) -> proc_macro2::TokenStream { let handler_name = &endpoint.handler_name; let method = endpoint.method.as_str(); let path = &version.path; + let body_limit_tokens = effective_body_limit_tokens(endpoint); let migration_type = &version.migration_type; let canonical_response_type = &endpoint.response_type; let legacy_response_type = &version.response_type; @@ -1366,8 +1546,14 @@ fn generate_legacy_handler_body( let canonical_parts_ident = quote::format_ident!("canonical_parts"); let mut canonical_args = rest_canonical_args_from_parts(endpoint, &canonical_parts_ident); + // Opt-in request headers, inserted before the auth arg is prepended below so + // the final order is [caller/user?, headers, path.., query.., body?]. + if endpoint.with_headers { + canonical_args.insert(0, quote! { headers.clone() }); + } + let json_handling = if version.request_type.is_some() { - generate_body_extraction() + generate_body_extraction(require_json, &body_limit_tokens, method, path) } else { quote! {} }; @@ -1408,7 +1594,7 @@ fn generate_legacy_handler_body( match <#migration_type as ras_rest_core::VersionMigration<#canonical_response_type, #legacy_response_type>>::migrate(rest_response.body) { Ok(body) => body, Err(e) => { - tracing::error!(error = %e, "Response migration failed"); + ras_rest_core::tracing::error!(error = %e, "Response migration failed"); return ( axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(serde_json::json!({ @@ -1417,16 +1603,13 @@ fn generate_legacy_handler_body( ).into_response(); }, }; - ( - status_code, - axum::Json(body) - ).into_response() + __ras_success_response(status_code, body) }, Err(rest_error) => { use axum::response::IntoResponse; if let Some(internal) = &rest_error.internal_error { - tracing::error!(error = ?internal, "Request failed with status {}", rest_error.status); + ras_rest_core::tracing::error!(error = ?internal, "Request failed with status {}", rest_error.status); } let status_code = axum::http::StatusCode::from_u16(rest_error.status) @@ -1498,7 +1681,7 @@ fn generate_legacy_handler_body( match <#migration_type as ras_rest_core::VersionMigration<#canonical_response_type, #legacy_response_type>>::migrate(rest_response.body) { Ok(body) => body, Err(e) => { - tracing::error!(error = %e, "Response migration failed"); + ras_rest_core::tracing::error!(error = %e, "Response migration failed"); return ( axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(serde_json::json!({ @@ -1507,16 +1690,13 @@ fn generate_legacy_handler_body( ).into_response(); }, }; - ( - status_code, - axum::Json(body) - ).into_response() + __ras_success_response(status_code, body) }, Err(rest_error) => { use axum::response::IntoResponse; if let Some(internal) = &rest_error.internal_error { - tracing::error!(error = ?internal, "Request failed with status {}", rest_error.status); + ras_rest_core::tracing::error!(error = ?internal, "Request failed with status {}", rest_error.status); } let status_code = axum::http::StatusCode::from_u16(rest_error.status) @@ -1591,7 +1771,7 @@ fn generate_legacy_handler_body( match <#migration_type as ras_rest_core::VersionMigration<#canonical_response_type, #legacy_response_type>>::migrate(rest_response.body) { Ok(body) => body, Err(e) => { - tracing::error!(error = %e, "Response migration failed"); + ras_rest_core::tracing::error!(error = %e, "Response migration failed"); return ( axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(serde_json::json!({ @@ -1600,16 +1780,13 @@ fn generate_legacy_handler_body( ).into_response(); }, }; - ( - status_code, - axum::Json(body) - ).into_response() + __ras_success_response(status_code, body) }, Err(rest_error) => { use axum::response::IntoResponse; if let Some(internal) = &rest_error.internal_error { - tracing::error!(error = ?internal, "Request failed with status {}", rest_error.status); + ras_rest_core::tracing::error!(error = ?internal, "Request failed with status {}", rest_error.status); } let status_code = axum::http::StatusCode::from_u16(rest_error.status) @@ -1676,30 +1853,147 @@ fn generate_axum_handler( } /// Generated code that reads and JSON-deserializes the request body from the -/// raw `request` extractor, bounded by `__RAS_BODY_LIMIT`. +/// raw `request` extractor, bounded by `limit`. /// /// For authenticated endpoints this must be emitted AFTER the /// auth/CSRF/permission block so unauthenticated clients cannot make the /// server buffer or parse payloads. -fn generate_body_extraction() -> proc_macro2::TokenStream { +/// +/// Behavior: +/// * When `require_json` is set, a request whose `Content-Type` is not +/// `application/json` (ignoring parameters like `; charset=utf-8`) is rejected +/// with `415 Unsupported Media Type` before the body is read. Requiring +/// `application/json` also forces a CORS preflight for cross-origin requests, +/// which no CORS layer answers by default — defense-in-depth against +/// simple-request CSRF on cookie-authenticated endpoints. +/// * A declared `Content-Length` over `limit` is rejected with `413` up front so +/// a subsequent `to_bytes` error is unambiguously a read failure (`400`), +/// rather than the two being conflated as "too large". +/// * A malformed JSON body is logged (category + line/column, never the rejected +/// value) at `warn` before returning `400`, matching the handler-error logging +/// convention. +fn generate_body_extraction( + require_json: bool, + limit: &proc_macro2::TokenStream, + method: &str, + path: &str, +) -> proc_macro2::TokenStream { + let content_type_check = if require_json { + quote! { + { + let __ras_content_type_ok = headers + .get(axum::http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| { + value + .split(';') + .next() + .unwrap_or("") + .trim() + .eq_ignore_ascii_case("application/json") + }) + .unwrap_or(false); + if !__ras_content_type_ok { + use axum::response::IntoResponse; + ras_rest_core::tracing::warn!( + method = #method, + path = #path, + "rejected request: Content-Type is not application/json" + ); + return ( + axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE, + axum::Json(serde_json::json!({ + "error": "Unsupported Media Type: expected application/json" + })) + ).into_response(); + } + } + } + } else { + quote! {} + }; + quote! { + #content_type_check + let body = { - let body_bytes = match ::axum::body::to_bytes(request.into_body(), __RAS_BODY_LIMIT).await { - Ok(bytes) => bytes, - Err(_) => { + // Reject an over-declared Content-Length up front so a 413 is + // unambiguous without reading the body. A chunked body with no + // declared length is still capped by `to_bytes`; that error is then + // classified below (over-limit -> 413, genuine read error -> 400). + if let Some(__ras_declared_len) = headers + .get(axum::http::header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + { + if __ras_declared_len > #limit { use axum::response::IntoResponse; + ras_rest_core::tracing::warn!( + method = #method, + path = #path, + declared_len = __ras_declared_len, + limit = #limit, + "rejected request: body exceeds limit" + ); return ( axum::http::StatusCode::PAYLOAD_TOO_LARGE, axum::Json(serde_json::json!({ - "error": "Request body too large or unreadable" + "error": "Request body too large" })) ).into_response(); + } + } + + let body_bytes = match ::axum::body::to_bytes(request.into_body(), #limit).await { + Ok(bytes) => bytes, + Err(__ras_body_err) => { + use axum::response::IntoResponse; + // `to_bytes` fails for both an over-limit body and a genuine + // stream read error. axum wraps http_body_util's + // `LengthLimitError` (Display: "length limit exceeded") for + // the former; classify on it so a read failure is a 400 and + // only a real overflow is a 413 — the two are no longer + // conflated. (A body carrying a declared `Content-Length` over + // the limit is already rejected above without being read.) + let (__ras_status, __ras_client_msg) = + if __ras_body_err.to_string().contains("length limit exceeded") { + ( + axum::http::StatusCode::PAYLOAD_TOO_LARGE, + "Request body too large", + ) + } else { + ( + axum::http::StatusCode::BAD_REQUEST, + "Could not read request body", + ) + }; + ras_rest_core::tracing::warn!( + method = #method, + path = #path, + status = __ras_status.as_u16(), + "rejected request: {}", + __ras_client_msg + ); + return ( + __ras_status, + axum::Json(serde_json::json!({ "error": __ras_client_msg })) + ).into_response(); }, }; match serde_json::from_slice(&body_bytes) { Ok(body) => body, - Err(_) => { + Err(__ras_json_err) => { use axum::response::IntoResponse; + // Log the classification and location so the reason is + // recoverable server-side; never log the rejected value. + ras_rest_core::tracing::warn!( + method = #method, + path = #path, + category = ?__ras_json_err.classify(), + line = __ras_json_err.line(), + column = __ras_json_err.column(), + "rejected request: malformed JSON body" + ); return ( axum::http::StatusCode::BAD_REQUEST, axum::Json(serde_json::json!({ @@ -1712,18 +2006,34 @@ fn generate_body_extraction() -> proc_macro2::TokenStream { } } +/// The effective body-size limit expression for an endpoint: its per-endpoint +/// `body_limit` override when set, otherwise the service-level `__RAS_BODY_LIMIT`. +fn effective_body_limit_tokens(endpoint: &EndpointDefinition) -> proc_macro2::TokenStream { + match endpoint.body_limit { + Some(limit) => quote! { #limit }, + None => quote! { __RAS_BODY_LIMIT }, + } +} + fn generate_handler_body( endpoint: &EndpointDefinition, handler_name: &Ident, method: &str, path: &str, + require_json: bool, ) -> proc_macro2::TokenStream { + let body_limit_tokens = effective_body_limit_tokens(endpoint); // Handle authentication if required match &endpoint.auth { AuthRequirement::Unauthorized => { // Build argument list for unauthorized endpoint let mut args = Vec::new(); + // Opt-in request headers (before path params) + if endpoint.with_headers { + args.push(quote! { headers.clone() }); + } + // Add path parameters if endpoint.path_params.len() == 1 { args.push(quote! { path_params }); @@ -1743,7 +2053,7 @@ fn generate_handler_body( // Handle JSON body extraction with error handling let json_handling = if endpoint.request_type.is_some() { args.push(quote! { body }); - generate_body_extraction() + generate_body_extraction(require_json, &body_limit_tokens, method, path) } else { quote! {} }; @@ -1763,20 +2073,16 @@ fn generate_handler_body( let result = match service.#handler_name(#(#args),*).await { Ok(rest_response) => { - use axum::response::IntoResponse; let status_code = axum::http::StatusCode::from_u16(rest_response.status) .unwrap_or(axum::http::StatusCode::OK); - ( - status_code, - axum::Json(rest_response.body) - ).into_response() + __ras_success_response(status_code, rest_response.body) }, Err(rest_error) => { use axum::response::IntoResponse; // Log internal error if present if let Some(internal) = &rest_error.internal_error { - tracing::error!(error = ?internal, "Request failed with status {}", rest_error.status); + ras_rest_core::tracing::error!(error = ?internal, "Request failed with status {}", rest_error.status); } let status_code = axum::http::StatusCode::from_u16(rest_error.status) @@ -1804,6 +2110,11 @@ fn generate_handler_body( // Build argument list; the caller is passed by value as the first arg. let mut args = vec![quote! { caller }]; + // Opt-in request headers (after the caller, before path params) + if endpoint.with_headers { + args.push(quote! { headers.clone() }); + } + // Add path parameters if endpoint.path_params.len() == 1 { args.push(quote! { path_params }); @@ -1823,7 +2134,7 @@ fn generate_handler_body( // Handle JSON body extraction with error handling let json_handling = if endpoint.request_type.is_some() { args.push(quote! { body }); - generate_body_extraction() + generate_body_extraction(require_json, &body_limit_tokens, method, path) } else { quote! {} }; @@ -1855,19 +2166,15 @@ fn generate_handler_body( let result = match service.#handler_name(#(#args),*).await { Ok(rest_response) => { - use axum::response::IntoResponse; let status_code = axum::http::StatusCode::from_u16(rest_response.status) .unwrap_or(axum::http::StatusCode::OK); - ( - status_code, - axum::Json(rest_response.body) - ).into_response() + __ras_success_response(status_code, rest_response.body) }, Err(rest_error) => { use axum::response::IntoResponse; if let Some(internal) = &rest_error.internal_error { - tracing::error!(error = ?internal, "Request failed with status {}", rest_error.status); + ras_rest_core::tracing::error!(error = ?internal, "Request failed with status {}", rest_error.status); } let status_code = axum::http::StatusCode::from_u16(rest_error.status) @@ -1895,6 +2202,11 @@ fn generate_handler_body( // Build argument list for authenticated endpoint let mut args = vec![quote! { &user }]; + // Opt-in request headers (after the user, before path params) + if endpoint.with_headers { + args.push(quote! { headers.clone() }); + } + // Add path parameters if endpoint.path_params.len() == 1 { args.push(quote! { path_params }); @@ -1914,7 +2226,7 @@ fn generate_handler_body( // Handle JSON body extraction with error handling let json_handling = if endpoint.request_type.is_some() { args.push(quote! { body }); - generate_body_extraction() + generate_body_extraction(require_json, &body_limit_tokens, method, path) } else { quote! {} }; @@ -1948,20 +2260,16 @@ fn generate_handler_body( let result = match service.#handler_name(#(#args),*).await { Ok(rest_response) => { - use axum::response::IntoResponse; let status_code = axum::http::StatusCode::from_u16(rest_response.status) .unwrap_or(axum::http::StatusCode::OK); - ( - status_code, - axum::Json(rest_response.body) - ).into_response() + __ras_success_response(status_code, rest_response.body) }, Err(rest_error) => { use axum::response::IntoResponse; // Log internal error if present if let Some(internal) = &rest_error.internal_error { - tracing::error!(error = ?internal, "Request failed with status {}", rest_error.status); + ras_rest_core::tracing::error!(error = ?internal, "Request failed with status {}", rest_error.status); } let status_code = axum::http::StatusCode::from_u16(rest_error.status) diff --git a/crates/rest/ras-rest-macro/src/static_hosting.rs b/crates/rest/ras-rest-macro/src/static_hosting.rs index 39ac0e2..559a64a 100644 --- a/crates/rest/ras-rest-macro/src/static_hosting.rs +++ b/crates/rest/ras-rest-macro/src/static_hosting.rs @@ -98,11 +98,70 @@ pub fn generate_static_routes( service_def.service_name.to_string().to_lowercase() ); + if !service_def.docs_require_auth { + // Default: docs and openapi.json are public (conventional API-explorer + // behavior). Documented as such on the `docs_require_auth` field. + return quote! { + { + router = router + .route(#docs_path, ::axum::routing::get(#docs_handler_name)) + .route(#openapi_path, ::axum::routing::get(openapi_json_handler)); + } + }; + } + + // Gated: require an authenticated caller (any authenticated user — empty + // permission groups) before serving the docs page or the OpenAPI document. + // Uses the same shared authorization pipeline as the endpoints. quote! { { + let auth_provider = self.auth_provider.clone(); + let auth_transport = self.auth_transport.clone(); router = router - .route(#docs_path, ::axum::routing::get(#docs_handler_name)) - .route(#openapi_path, ::axum::routing::get(openapi_json_handler)); + .route(#docs_path, ::axum::routing::get({ + let auth_provider = auth_provider.clone(); + let auth_transport = auth_transport.clone(); + move |headers: ::axum::http::HeaderMap| { + let auth_provider = auth_provider.clone(); + let auth_transport = auth_transport.clone(); + async move { + use ::axum::response::IntoResponse; + let __ras_docs_groups: Vec> = Vec::new(); + match ras_auth_core::authorize_request( + "GET", + &headers, + &auth_transport, + auth_provider.as_deref(), + &__ras_docs_groups, + ).await { + Ok(_) => #docs_handler_name().await.into_response(), + Err(error) => __ras_authorize_error_response(error), + } + } + } + })) + .route(#openapi_path, ::axum::routing::get({ + let auth_provider = auth_provider.clone(); + let auth_transport = auth_transport.clone(); + move |headers: ::axum::http::HeaderMap| { + let auth_provider = auth_provider.clone(); + let auth_transport = auth_transport.clone(); + async move { + use ::axum::response::IntoResponse; + let __ras_docs_groups: Vec> = Vec::new(); + match ras_auth_core::authorize_request( + "GET", + &headers, + &auth_transport, + auth_provider.as_deref(), + &__ras_docs_groups, + ).await { + Ok(_) => openapi_json_handler().await.into_response(), + Err(error) => __ras_authorize_error_response(error), + } + } + } + })); } } } diff --git a/crates/rest/ras-rest-macro/tests/xm_feedback_hardening_test.rs b/crates/rest/ras-rest-macro/tests/xm_feedback_hardening_test.rs new file mode 100644 index 0000000..bb04bc2 --- /dev/null +++ b/crates/rest/ras-rest-macro/tests/xm_feedback_hardening_test.rs @@ -0,0 +1,430 @@ +//! Regression tests for the `rest_service!` hardening prompted by the XM +//! device-integration feedback: +//! +//! * Content-Type enforcement (strict `application/json`, opt-out). +//! * `413` vs `400` split for over-limit vs unreadable bodies. +//! * `204 No Content` no longer carries a serialized body. +//! * Per-endpoint `body_limit` override. +//! * Opt-in request-header parameter for handlers. +//! * Startup assertion when a permissioned service has no auth provider. +//! * `docs_require_auth` gate on the docs / openapi routes. +//! +//! Each of these fails against the pre-hardening macro. + +use axum_test::TestServer; +use ras_auth_core::{AuthError, AuthProvider, AuthenticatedUser}; +use ras_rest_core::{RestResponse, RestResult}; +use ras_rest_macro::rest_service; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::collections::HashSet; +use std::pin::Pin; + +#[derive(Clone)] +struct MockAuth; + +impl AuthProvider for MockAuth { + fn authenticate( + &self, + authorization: String, + ) -> Pin> + Send + '_>> + { + Box::pin(async move { + if authorization == "admin-token" { + let mut permissions = HashSet::new(); + permissions.insert("admin".to_string()); + Ok(AuthenticatedUser { + user_id: "admin".to_string(), + permissions, + metadata: None, + }) + } else { + Err(AuthError::InvalidToken) + } + }) + } + + fn check_permissions( + &self, + user: &AuthenticatedUser, + required_permissions: &[String], + ) -> Result<(), AuthError> { + if required_permissions + .iter() + .all(|perm| user.permissions.contains(perm)) + { + Ok(()) + } else { + Err(AuthError::InsufficientPermissions { + required: required_permissions.to_vec(), + has: user.permissions.iter().cloned().collect(), + }) + } + } +} + +// --------------------------------------------------------------------------- +// Content-Type enforcement (strict default) + opt-out +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] +struct Payload { + value: String, +} + +rest_service!({ + service_name: StrictService, + base_path: "/strict", + endpoints: [ + POST UNAUTHORIZED submit(Payload) -> Value, + ] +}); + +struct StrictImpl; + +#[async_trait::async_trait] +impl StrictServiceTrait for StrictImpl { + async fn post_submit(&self, request: Payload) -> RestResult { + Ok(RestResponse::ok(json!({ "echo": request.value }))) + } +} + +rest_service!({ + service_name: LenientService, + base_path: "/lenient", + require_json_content_type: false, + endpoints: [ + POST UNAUTHORIZED submit(Payload) -> Value, + ] +}); + +struct LenientImpl; + +#[async_trait::async_trait] +impl LenientServiceTrait for LenientImpl { + async fn post_submit(&self, request: Payload) -> RestResult { + Ok(RestResponse::ok(json!({ "echo": request.value }))) + } +} + +#[tokio::test] +async fn strict_service_requires_json_content_type() { + let app = StrictServiceBuilder::new(StrictImpl).build(); + let server = TestServer::builder().mock_transport().build(app).unwrap(); + + // application/json → accepted. + let ok = server + .post("/strict/submit") + .json(&json!({ "value": "hi" })) + .await; + assert_eq!(ok.status_code().as_u16(), 200); + + // text/plain carrying JSON-parseable bytes → 415, before the body is parsed. + // (text/plain is CORS-safelisted, so this is the simple-request CSRF shape.) + let rejected = server + .post("/strict/submit") + .text(json!({ "value": "hi" }).to_string()) + .content_type("text/plain") + .await; + assert_eq!(rejected.status_code().as_u16(), 415); + + // No Content-Type at all → 415. + let missing = server + .post("/strict/submit") + .bytes(json!({ "value": "hi" }).to_string().into_bytes().into()) + .content_type("") + .await; + assert_eq!(missing.status_code().as_u16(), 415); +} + +#[tokio::test] +async fn strict_service_accepts_json_with_charset_parameter() { + let app = StrictServiceBuilder::new(StrictImpl).build(); + let server = TestServer::builder().mock_transport().build(app).unwrap(); + + let ok = server + .post("/strict/submit") + .text(json!({ "value": "hi" }).to_string()) + .content_type("application/json; charset=utf-8") + .await; + assert_eq!(ok.status_code().as_u16(), 200); +} + +#[tokio::test] +async fn lenient_service_accepts_any_content_type() { + let app = LenientServiceBuilder::new(LenientImpl).build(); + let server = TestServer::builder().mock_transport().build(app).unwrap(); + + let ok = server + .post("/lenient/submit") + .text(json!({ "value": "hi" }).to_string()) + .content_type("text/plain") + .await; + assert_eq!(ok.status_code().as_u16(), 200); +} + +// --------------------------------------------------------------------------- +// 204 No Content must not carry a body +// --------------------------------------------------------------------------- + +rest_service!({ + service_name: NoContentService, + base_path: "/nc", + endpoints: [ + DELETE UNAUTHORIZED thing() -> (), + ] +}); + +struct NoContentImpl; + +#[async_trait::async_trait] +impl NoContentServiceTrait for NoContentImpl { + async fn delete_thing(&self) -> RestResult<()> { + Ok(RestResponse::no_content()) + } +} + +#[tokio::test] +async fn no_content_response_has_empty_body() { + let app = NoContentServiceBuilder::new(NoContentImpl).build(); + let server = TestServer::builder().mock_transport().build(app).unwrap(); + + let response = server.delete("/nc/thing").await; + assert_eq!(response.status_code().as_u16(), 204); + // Before the fix this carried a serialized `null` body. + assert!( + response.as_bytes().is_empty(), + "204 response should have no body, got {:?}", + response.as_bytes() + ); +} + +// A 204 on a NON-unit response type must also emit an empty body — the case +// that broke the generated client (it deserialized the empty body as EOF). +rest_service!({ + service_name: MaybeService, + base_path: "/maybe", + endpoints: [ + GET UNAUTHORIZED maybe() -> Option, + ] +}); + +struct MaybeImpl; + +#[async_trait::async_trait] +impl MaybeServiceTrait for MaybeImpl { + async fn get_maybe(&self) -> RestResult> { + Ok(RestResponse::no_content()) + } +} + +#[tokio::test] +async fn no_content_with_non_unit_response_type_has_empty_body() { + let app = MaybeServiceBuilder::new(MaybeImpl).build(); + let server = TestServer::builder().mock_transport().build(app).unwrap(); + + let response = server.get("/maybe/maybe").await; + assert_eq!(response.status_code().as_u16(), 204); + assert!( + response.as_bytes().is_empty(), + "204 with Option response should still have no body, got {:?}", + response.as_bytes() + ); +} + +// The 413 Content-Length precheck (distinct from the to_bytes fallback) fires +// only when a Content-Length header is present. A real HTTP transport sets it, +// so this exercises the precheck path that mock_transport never reaches. +rest_service!({ + service_name: ClLimitService, + base_path: "/cl", + body_limit: 64, + endpoints: [ + POST UNAUTHORIZED echo(Value) -> Value, + ] +}); + +struct ClLimitImpl; + +#[async_trait::async_trait] +impl ClLimitServiceTrait for ClLimitImpl { + async fn post_echo(&self, request: Value) -> RestResult { + Ok(RestResponse::ok(request)) + } +} + +#[tokio::test] +async fn content_length_precheck_returns_413() { + let app = ClLimitServiceBuilder::new(ClLimitImpl).build(); + let server = TestServer::builder().http_transport().build(app).unwrap(); + + let response = server + .post("/cl/echo") + .json(&json!({ "data": "x".repeat(256) })) + .await; + assert_eq!(response.status_code().as_u16(), 413); +} + +// --------------------------------------------------------------------------- +// Per-endpoint body_limit override +// --------------------------------------------------------------------------- + +rest_service!({ + service_name: PerEndpointLimitService, + base_path: "/pel", + body_limit: 1048576, + endpoints: [ + POST UNAUTHORIZED small(Value) -> Value { body_limit: 16 }, + ] +}); + +struct PerEndpointLimitImpl; + +#[async_trait::async_trait] +impl PerEndpointLimitServiceTrait for PerEndpointLimitImpl { + async fn post_small(&self, request: Value) -> RestResult { + Ok(RestResponse::ok(request)) + } +} + +#[tokio::test] +async fn per_endpoint_body_limit_overrides_service_limit() { + let app = PerEndpointLimitServiceBuilder::new(PerEndpointLimitImpl).build(); + let server = TestServer::builder().mock_transport().build(app).unwrap(); + + // Well over the 16-byte endpoint cap (but under the 1 MiB service cap). + let response = server + .post("/pel/small") + .json(&json!({ "data": "x".repeat(64) })) + .await; + assert_eq!(response.status_code().as_u16(), 413); +} + +// --------------------------------------------------------------------------- +// Opt-in request headers in the handler signature +// --------------------------------------------------------------------------- + +rest_service!({ + service_name: HeaderService, + base_path: "/hdr", + endpoints: [ + POST UNAUTHORIZED echo(Payload) -> Value { headers: true }, + ] +}); + +struct HeaderImpl; + +#[async_trait::async_trait] +impl HeaderServiceTrait for HeaderImpl { + async fn post_echo( + &self, + headers: axum::http::HeaderMap, + request: Payload, + ) -> RestResult { + let device = headers + .get("x-device-id") + .and_then(|v| v.to_str().ok()) + .unwrap_or("none") + .to_string(); + Ok(RestResponse::ok(json!({ + "device": device, + "value": request.value, + }))) + } +} + +#[tokio::test] +async fn handler_receives_opt_in_headers() { + let app = HeaderServiceBuilder::new(HeaderImpl).build(); + let server = TestServer::builder().mock_transport().build(app).unwrap(); + + let response = server + .post("/hdr/echo") + .add_header("x-device-id", "chassis-42") + .json(&json!({ "value": "ping" })) + .await; + assert_eq!(response.status_code().as_u16(), 200); + let body: Value = response.json(); + assert_eq!(body["device"], "chassis-42"); + assert_eq!(body["value"], "ping"); +} + +// --------------------------------------------------------------------------- +// Startup assertion: permissioned service without an auth provider +// --------------------------------------------------------------------------- + +rest_service!({ + service_name: NeedsProviderService, + base_path: "/np", + endpoints: [ + GET WITH_PERMISSIONS(["admin"]) secret() -> Value, + ] +}); + +struct NeedsProviderImpl; + +#[async_trait::async_trait] +impl NeedsProviderServiceTrait for NeedsProviderImpl { + async fn get_secret(&self, _user: &AuthenticatedUser) -> RestResult { + Ok(RestResponse::ok(json!({ "ok": true }))) + } +} + +#[test] +#[should_panic(expected = "auth_provider")] +fn build_panics_when_permissioned_service_has_no_provider() { + // No `.auth_provider(...)` — must panic at build() rather than 500 at runtime. + let _ = NeedsProviderServiceBuilder::new(NeedsProviderImpl).build(); +} + +// --------------------------------------------------------------------------- +// docs_require_auth gate +// --------------------------------------------------------------------------- + +rest_service!({ + service_name: GatedDocsService, + base_path: "/gd", + openapi: true, + serve_docs: true, + docs_path: "/docs", + docs_require_auth: true, + endpoints: [ + GET WITH_PERMISSIONS(["admin"]) secret() -> Value, + ] +}); + +struct GatedDocsImpl; + +#[async_trait::async_trait] +impl GatedDocsServiceTrait for GatedDocsImpl { + async fn get_secret(&self, _user: &AuthenticatedUser) -> RestResult { + Ok(RestResponse::ok(json!({ "ok": true }))) + } +} + +#[tokio::test] +async fn gated_docs_require_authentication() { + let app = GatedDocsServiceBuilder::new(GatedDocsImpl) + .auth_provider(MockAuth) + .build(); + let server = TestServer::builder().mock_transport().build(app).unwrap(); + + // Unauthenticated docs + openapi → rejected. + assert_eq!(server.get("/gd/docs").await.status_code().as_u16(), 401); + assert_eq!( + server.get("/gd/docs/openapi.json").await.status_code().as_u16(), + 401 + ); + + // With a valid credential → served. + let docs = server + .get("/gd/docs") + .authorization_bearer("admin-token") + .await; + assert_eq!(docs.status_code().as_u16(), 200); + let spec = server + .get("/gd/docs/openapi.json") + .authorization_bearer("admin-token") + .await; + assert_eq!(spec.status_code().as_u16(), 200); +} diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/Cargo.toml b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/Cargo.toml index 61b0d39..12c7473 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/Cargo.toml +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ras-jsonrpc-bidirectional-client" -version = "0.1.0" +version = "0.2.0" edition = "2024" rust-version = "1.88" description = "Cross-platform WebSocket client for bidirectional JSON-RPC communication" @@ -11,9 +11,9 @@ readme = "README.md" [dependencies] # Core dependencies -ras-jsonrpc-types = { path = "../../ras-jsonrpc-types", version = "0.1.1" } -ras-jsonrpc-bidirectional-types = { path = "../ras-jsonrpc-bidirectional-types", version = "0.1.0" } -ras-auth-core = { path = "../../../core/ras-auth-core", version = "0.1.0" } +ras-jsonrpc-types = { path = "../../ras-jsonrpc-types", version = "0.2.0" } +ras-jsonrpc-bidirectional-types = { path = "../ras-jsonrpc-bidirectional-types", version = "0.2.0" } +ras-auth-core = { path = "../../../core/ras-auth-core", version = "0.2.0" } # Async and serialization serde = { workspace = true } diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/README.md b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/README.md index 24e4764..9c53cec 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/README.md +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/README.md @@ -32,7 +32,7 @@ For native clients: ```toml [dependencies] -ras-jsonrpc-bidirectional-client = "0.1.0" +ras-jsonrpc-bidirectional-client = "0.2.0" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tokio = { version = "1.0", features = ["full"] } diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/Cargo.toml b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/Cargo.toml index b61bb83..ef23e27 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/Cargo.toml +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ras-jsonrpc-bidirectional-macro" -version = "0.1.0" +version = "0.2.0" edition = "2024" rust-version = "1.88" description = "Procedural macro for bidirectional JSON-RPC services" @@ -26,12 +26,12 @@ client = [] permissions = [] [dev-dependencies] -ras-jsonrpc-bidirectional-types = { path = "../ras-jsonrpc-bidirectional-types", version = "0.1.0" } -ras-jsonrpc-bidirectional-server = { path = "../ras-jsonrpc-bidirectional-server", version = "0.1.0" } -ras-jsonrpc-bidirectional-client = { path = "../ras-jsonrpc-bidirectional-client", version = "0.1.0" } -ras-auth-core = { path = "../../../core/ras-auth-core", version = "0.1.0" } +ras-jsonrpc-bidirectional-types = { path = "../ras-jsonrpc-bidirectional-types", version = "0.2.0" } +ras-jsonrpc-bidirectional-server = { path = "../ras-jsonrpc-bidirectional-server", version = "0.2.0" } +ras-jsonrpc-bidirectional-client = { path = "../ras-jsonrpc-bidirectional-client", version = "0.2.0" } +ras-auth-core = { path = "../../../core/ras-auth-core", version = "0.2.0" } ras-permission-manifest = { path = "../../../specs/ras-permission-manifest", version = "0.1.0" } -ras-jsonrpc-types = { path = "../../ras-jsonrpc-types", version = "0.1.1" } +ras-jsonrpc-types = { path = "../../ras-jsonrpc-types", version = "0.2.0" } tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/README.md b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/README.md index 7a45175..1b30533 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/README.md +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/README.md @@ -25,12 +25,12 @@ Add this to your `Cargo.toml`: async-trait = "0.1" serde = { version = "1", features = ["derive"] } serde_json = "1" -ras-auth-core = "0.1.0" -ras-jsonrpc-types = "0.1.1" -ras-jsonrpc-bidirectional-types = "0.1.0" -ras-jsonrpc-bidirectional-macro = { version = "0.1.0", default-features = false } -ras-jsonrpc-bidirectional-server = { version = "0.1.0", optional = true } -ras-jsonrpc-bidirectional-client = { version = "0.1.0", optional = true } +ras-auth-core = "0.2.0" +ras-jsonrpc-types = "0.2.0" +ras-jsonrpc-bidirectional-types = "0.2.0" +ras-jsonrpc-bidirectional-macro = { version = "0.2.0", default-features = false } +ras-jsonrpc-bidirectional-server = { version = "0.2.0", optional = true } +ras-jsonrpc-bidirectional-client = { version = "0.2.0", optional = true } [features] default = [] diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/lib.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/lib.rs index d1d4e62..a9b905a 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/lib.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/lib.rs @@ -251,6 +251,18 @@ impl Parse for MethodDefinition { permission_groups.push(group); } + if permission_groups.len() > 1 + && permission_groups.iter().any(|group| group.is_empty()) + { + return Err(syn::Error::new( + auth_ident.span(), + "an empty permission group is only valid as the entire requirement \ + (WITH_PERMISSIONS([]), meaning any authenticated user); mixing an \ + empty group with non-empty groups would silently grant access to any \ + authenticated user", + )); + } + AuthRequirement::WithPermissions(permission_groups) } _ => { diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/Cargo.toml b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/Cargo.toml index a6d454b..1cb467c 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/Cargo.toml +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ras-jsonrpc-bidirectional-server" -version = "0.1.0" +version = "0.2.0" edition = "2024" rust-version = "1.88" description = "WebSocket server implementation for bidirectional JSON-RPC communication" @@ -24,9 +24,9 @@ bon = { workspace = true } chrono = { workspace = true } # Internal dependencies -ras-auth-core = { path = "../../../core/ras-auth-core", version = "0.1.0" } -ras-jsonrpc-types = { path = "../../ras-jsonrpc-types", version = "0.1.1" } -ras-jsonrpc-bidirectional-types = { path = "../ras-jsonrpc-bidirectional-types", version = "0.1.0" } +ras-auth-core = { path = "../../../core/ras-auth-core", version = "0.2.0" } +ras-jsonrpc-types = { path = "../../ras-jsonrpc-types", version = "0.2.0" } +ras-jsonrpc-bidirectional-types = { path = "../ras-jsonrpc-bidirectional-types", version = "0.2.0" } # WebSocket specific dependencies futures = { workspace = true } @@ -35,5 +35,5 @@ futures = { workspace = true } dashmap = { workspace = true } [dev-dependencies] -ras-jsonrpc-types = { path = "../../ras-jsonrpc-types", version = "0.1.1" } +ras-jsonrpc-types = { path = "../../ras-jsonrpc-types", version = "0.2.0" } tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/error.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/error.rs index 52fb538..3cae4df 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/error.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/error.rs @@ -54,6 +54,28 @@ pub enum ServerError { } impl ServerError { + /// Generic, non-sensitive message safe to send to a client. + /// + /// The [`Display`](std::fmt::Display) impl keeps full detail for server logs; + /// this is what goes on the wire (JSON-RPC error message / upgrade HTTP body) + /// so handler internals, DSNs, auth specifics, or `AuthError` fields never + /// leak to clients (H3). Mirrors `FileError::client_message`. + pub fn client_message(&self) -> &'static str { + match self { + ServerError::AuthenticationFailed(_) => "Authentication failed", + ServerError::PermissionDenied(_) => "Insufficient permissions", + ServerError::ConnectionNotFound(_) => "Connection not found", + ServerError::InvalidRequest(_) => "Invalid request", + ServerError::HandlerNotFound(_) => "Method not found", + ServerError::SerializationError(_) => "Invalid parameters", + ServerError::UpgradeFailed(_) + | ServerError::RoutingFailed(_) + | ServerError::WebSocketError(_) + | ServerError::ConnectionError(_) + | ServerError::Internal(_) => "Internal error", + } + } + /// Convert to HTTP status code for upgrade errors pub fn to_status_code(&self) -> StatusCode { match self { @@ -136,4 +158,23 @@ mod tests { .starts_with("WebSocket upgrade failed:") ); } + + #[test] + fn client_message_never_leaks_internal_detail() { + // Handler internals must not reach the client (H3). + let internal = ServerError::Internal("database password is hunter2".into()); + assert_eq!(internal.client_message(), "Internal error"); + assert!(!internal.client_message().contains("hunter2")); + + // AuthError detail (DSNs, Internal(...) strings) must not reach the client. + let auth = ServerError::AuthenticationFailed(AuthError::Internal( + "dsn=postgres://user:pw@host/db".into(), + )); + assert_eq!(auth.client_message(), "Authentication failed"); + assert!(!auth.client_message().contains("dsn")); + + // But the full detail is still available in Display for server logs. + assert!(internal.to_string().contains("hunter2")); + assert!(auth.to_string().contains("dsn=postgres")); + } } diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler.rs index 1733c9e..926a6b4 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler.rs @@ -544,7 +544,9 @@ fn jsonrpc_error_from_server_error(error: &ServerError) -> JsonRpcError { | ServerError::Internal(_) => error_codes::INTERNAL_ERROR, }; - JsonRpcError::new(code, error.to_string(), None) + // Send only a generic per-class message; the full error was already logged + // server-side by the caller. Never interpolate handler/AuthError Display (H3). + JsonRpcError::new(code, error.client_message().to_string(), None) } #[cfg(test)] @@ -555,6 +557,38 @@ mod tests { use std::collections::VecDeque; use std::sync::Mutex; + #[test] + fn jsonrpc_error_from_server_error_sends_generic_message_not_handler_detail() { + // Handler error carrying a secret -> client sees only a generic message, + // stable code preserved, no data field (H3). + let err = ServerError::Internal("database password is hunter2".into()); + let jsonrpc = jsonrpc_error_from_server_error(&err); + assert_eq!(jsonrpc.code, error_codes::INTERNAL_ERROR); + assert_eq!(jsonrpc.message, "Internal error"); + assert!(!jsonrpc.message.contains("hunter2")); + assert!(jsonrpc.data.is_none()); + + // AuthError detail must not reach the client either. + let auth = ServerError::AuthenticationFailed(ras_auth_core::AuthError::Internal( + "dsn=postgres://user:pw@host/db".into(), + )); + let jsonrpc = jsonrpc_error_from_server_error(&auth); + assert_eq!(jsonrpc.code, error_codes::AUTHENTICATION_REQUIRED); + assert_eq!(jsonrpc.message, "Authentication failed"); + assert!(!jsonrpc.message.contains("dsn")); + + // Stable codes for the invalid-request / method-not-found classes. + assert_eq!( + jsonrpc_error_from_server_error(&ServerError::InvalidRequest("Invalid params: x".into())) + .code, + error_codes::INVALID_REQUEST + ); + assert_eq!( + jsonrpc_error_from_server_error(&ServerError::HandlerNotFound("m".into())).code, + error_codes::METHOD_NOT_FOUND + ); + } + /// A minimal MessageHandler that only implements the required method — /// every other method falls through to the default impl, which is what /// these tests are verifying. @@ -858,7 +892,9 @@ mod tests { assert_eq!(error_response.id, Some(serde_json::json!(1))); let error = error_response.error.as_ref().expect("JSON-RPC error"); assert_eq!(error.code, ras_jsonrpc_types::error_codes::INVALID_REQUEST); - assert_eq!(error.message, "Invalid request: bad request"); + // Message is the generic per-class string; the handler's detail + // ("bad request") stays server-side (H3). + assert_eq!(error.message, "Invalid request"); let success_response = match &messages[2] { BidirectionalMessage::Response(response) => response, diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/upgrade.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/upgrade.rs index 673a9c9..3fb7963 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/upgrade.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/upgrade.rs @@ -81,8 +81,11 @@ impl WebSocketUpgrade { Ok(response) } Err(e) => { + // Log the real error server-side; the HTTP body gets only a + // generic per-class message so AuthError internals (required/has + // permission lists, Internal(...) strings) never leak (H3). error!("Authentication failed during WebSocket upgrade: {}", e); - Err((e.to_status_code(), e.to_string())) + Err((e.to_status_code(), e.client_message().to_string())) } } } @@ -119,26 +122,40 @@ impl WebSocketUpgrade { } fn extract_auth_token_from_headers(headers: &HeaderMap) -> Option { - if let Some(auth_header) = headers.get("authorization") - && let Ok(auth_str) = auth_header.to_str() - { - if let Some(token) = auth_str.strip_prefix("Bearer ") { - return Some(token.to_string()); - } - return Some(auth_str.to_string()); + // Only `Authorization: Bearer ` is a bearer token, matching the HTTP + // transport (`ras_auth_core::extract_auth_credential`). A raw value or any + // other scheme (`Basic ...`) is NOT a token, and a present-but-malformed + // Authorization header does not fall through to a weaker transport (M5). + if let Some(auth_header) = headers.get("authorization") { + let Ok(auth_str) = auth_header.to_str() else { + return None; + }; + return match auth_str.split_once(' ') { + Some((scheme, token)) + if scheme.eq_ignore_ascii_case("Bearer") && !token.trim().is_empty() => + { + Some(token.trim().to_string()) + } + _ => None, + }; } + // Browser fallback: `Sec-WebSocket-Protocol: token.`. Documented as a + // last-resort transport; the raw protocol value must never be logged (it is + // in `redact_sensitive_headers`'s list). if let Some(token_header) = headers.get("sec-websocket-protocol") && let Ok(token_str) = token_header.to_str() && let Some(token) = token_str.strip_prefix("token.") + && !token.trim().is_empty() { - return Some(token.to_string()); + return Some(token.trim().to_string()); } if let Some(token_header) = headers.get("x-auth-token") && let Ok(token_str) = token_header.to_str() + && !token_str.trim().is_empty() { - return Some(token_str.to_string()); + return Some(token_str.trim().to_string()); } None @@ -176,6 +193,12 @@ fn get_header_value(headers: &HeaderMap, name: &str) -> Option { .map(|s| s.to_string()) } +/// Extract a client-claimed IP from forwarding headers. +/// +/// These headers are entirely client-controllable and there is no trusted-proxy +/// allowlist here, so the result is a *claim*, not a verified address. It is +/// exposed as connection metadata only and must never drive an authorization, +/// rate-limit, or audit decision as-is (M5). fn extract_client_ip_from_headers(headers: &HeaderMap) -> Option { let ip_headers = [ "x-forwarded-for", @@ -203,7 +226,11 @@ fn create_metadata_from_headers(headers: &HeaderMap) -> serde_json::Value { let mut metadata = serde_json::Map::new(); if let Some(ip) = extract_client_ip_from_headers(headers) { - metadata.insert("client_ip".to_string(), serde_json::Value::String(ip)); + // Named `claimed_` because the value is unauthenticated client input. + metadata.insert( + "claimed_client_ip".to_string(), + serde_json::Value::String(ip), + ); } if let Some(user_agent) = get_header_value(headers, "user-agent") { @@ -278,16 +305,52 @@ mod tests { } #[test] - fn extracts_authorization_raw_token() { + fn rejects_raw_authorization_value_as_token() { + // A bare value with no `Bearer ` scheme is not a token (M5). let mut headers = HeaderMap::new(); headers.insert("authorization", HeaderValue::from_static("raw-token")); + assert_eq!(extract_auth_token_from_headers(&headers), None); + } + + #[test] + fn rejects_non_bearer_authorization_schemes() { + let mut headers = HeaderMap::new(); + headers.insert("authorization", HeaderValue::from_static("Basic abc123")); + + assert_eq!(extract_auth_token_from_headers(&headers), None); + } + + #[test] + fn accepts_case_insensitive_bearer_scheme() { + let mut headers = HeaderMap::new(); + headers.insert("authorization", HeaderValue::from_static("bearer abc123")); + assert_eq!( extract_auth_token_from_headers(&headers), - Some("raw-token".to_string()) + Some("abc123".to_string()) ); } + #[test] + fn rejects_empty_bearer_token() { + let mut headers = HeaderMap::new(); + headers.insert("authorization", HeaderValue::from_static("Bearer ")); + + assert_eq!(extract_auth_token_from_headers(&headers), None); + } + + #[test] + fn malformed_authorization_does_not_fall_through_to_other_transports() { + // A present-but-invalid Authorization header must not silently authenticate + // via a weaker transport, matching the HTTP path. + let mut headers = HeaderMap::new(); + headers.insert("authorization", HeaderValue::from_static("Basic abc")); + headers.insert("x-auth-token", HeaderValue::from_static("fallback")); + + assert_eq!(extract_auth_token_from_headers(&headers), None); + } + #[test] fn extracts_websocket_protocol_token_before_x_auth_token() { let mut headers = HeaderMap::new(); @@ -364,7 +427,10 @@ mod tests { let metadata = create_metadata_from_headers(&headers); - assert_eq!(metadata.get("client_ip").expect("client ip"), "127.0.0.1"); + assert_eq!( + metadata.get("claimed_client_ip").expect("claimed client ip"), + "127.0.0.1" + ); assert_eq!( metadata.get("user_agent").expect("user agent"), "test-agent" @@ -406,7 +472,10 @@ mod tests { #[tokio::test] async fn authenticate_headers_wraps_provider_errors() { let mut headers = HeaderMap::new(); - headers.insert("authorization", HeaderValue::from_static("expired")); + headers.insert( + "authorization", + HeaderValue::from_static("Bearer expired-token"), + ); let provider = RecordingAuthProvider::returning(Err(AuthError::TokenExpired)); let error = authenticate_headers(&headers, &provider) @@ -414,7 +483,9 @@ mod tests { .expect_err("auth failure is propagated"); assert_eq!(error.to_status_code(), StatusCode::UNAUTHORIZED); + // Display keeps detail for logs; client_message stays generic (H3). assert_eq!(error.to_string(), "Authentication failed: Token expired"); - assert_eq!(provider.tokens(), vec!["expired".to_string()]); + assert_eq!(error.client_message(), "Authentication failed"); + assert_eq!(provider.tokens(), vec!["expired-token".to_string()]); } } diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/Cargo.toml b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/Cargo.toml index cc5dae3..d8de63f 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/Cargo.toml +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ras-jsonrpc-bidirectional-types" -version = "0.1.0" +version = "0.2.0" edition = "2024" rust-version = "1.88" description = "Shared types for bidirectional JSON-RPC clients and servers" @@ -17,8 +17,8 @@ futures = { workspace = true } thiserror = { workspace = true } async-trait = { workspace = true } tracing = { workspace = true } -ras-auth-core = { path = "../../../core/ras-auth-core", version = "0.1.0" } -ras-jsonrpc-types = { path = "../../ras-jsonrpc-types", version = "0.1.1" } +ras-auth-core = { path = "../../../core/ras-auth-core", version = "0.2.0" } +ras-jsonrpc-types = { path = "../../ras-jsonrpc-types", version = "0.2.0" } uuid = { version = "1.11", features = ["v4", "serde", "js"] } chrono = { workspace = true } diff --git a/crates/rpc/ras-jsonrpc-core/Cargo.toml b/crates/rpc/ras-jsonrpc-core/Cargo.toml index 253d011..d2d93fb 100644 --- a/crates/rpc/ras-jsonrpc-core/Cargo.toml +++ b/crates/rpc/ras-jsonrpc-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ras-jsonrpc-core" -version = "0.1.2" +version = "0.2.0" edition = "2024" rust-version = "1.88" description = "Core types and traits for the ras-jsonrpc crate family" @@ -13,6 +13,7 @@ readme = "README.md" serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } -ras-jsonrpc-types = { path = "../ras-jsonrpc-types", version = "0.1.1" } -ras-auth-core = { path = "../../core/ras-auth-core", version = "0.1.0" } +ras-jsonrpc-types = { path = "../ras-jsonrpc-types", version = "0.2.0" } +ras-auth-core = { path = "../../core/ras-auth-core", version = "0.2.0" } ras-version-core = { path = "../../core/ras-version-core", version = "0.1.0" } +tracing = { workspace = true } diff --git a/crates/rpc/ras-jsonrpc-core/README.md b/crates/rpc/ras-jsonrpc-core/README.md index ed90fb0..73d0ff2 100644 --- a/crates/rpc/ras-jsonrpc-core/README.md +++ b/crates/rpc/ras-jsonrpc-core/README.md @@ -22,7 +22,7 @@ Add this to your `Cargo.toml`: ```toml [dependencies] -ras-jsonrpc-core = "0.1.2" +ras-jsonrpc-core = "0.2.0" ``` ### Implementing an Auth Provider diff --git a/crates/rpc/ras-jsonrpc-core/src/lib.rs b/crates/rpc/ras-jsonrpc-core/src/lib.rs index 8c54cb8..6ee8c6a 100644 --- a/crates/rpc/ras-jsonrpc-core/src/lib.rs +++ b/crates/rpc/ras-jsonrpc-core/src/lib.rs @@ -13,6 +13,10 @@ pub use ras_jsonrpc_types::*; // Re-export version migration traits for generated compatibility dispatch. pub use ras_version_core::*; +// Re-export `tracing` so generated server code can log without requiring every +// JSON-RPC consumer crate to declare a direct `tracing` dependency. +pub use tracing; + #[cfg(test)] mod tests { use super::*; @@ -119,19 +123,16 @@ mod tests { } #[test] - fn reexported_jsonrpc_error_encodes_permission_details() { - let error = JsonRpcError::insufficient_permissions( - vec!["widgets:write".to_string()], - vec!["widgets:read".to_string()], - ); + fn reexported_jsonrpc_error_encodes_required_but_not_caller_grants() { + let error = JsonRpcError::insufficient_permissions(vec!["widgets:write".to_string()]); assert_eq!(error.code, error_codes::INSUFFICIENT_PERMISSIONS); assert_eq!(error.message, "Insufficient permissions"); + // `required` is advertised; the caller's grant set is never echoed (M1). assert_eq!( error.data, Some(json!({ "required": ["widgets:write"], - "has": ["widgets:read"] })) ); } diff --git a/crates/rpc/ras-jsonrpc-macro/Cargo.toml b/crates/rpc/ras-jsonrpc-macro/Cargo.toml index afe46c5..01048e5 100644 --- a/crates/rpc/ras-jsonrpc-macro/Cargo.toml +++ b/crates/rpc/ras-jsonrpc-macro/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ras-jsonrpc-macro" -version = "0.2.0" +version = "0.3.0" edition = "2024" rust-version = "1.88" description = "Procedural macro for type-safe JSON-RPC interfaces with auth integration and OpenRPC document generation" @@ -30,26 +30,26 @@ schemars = { workspace = true } # Server dependencies axum = { workspace = true, optional = true } -ras-jsonrpc-core = { path = "../ras-jsonrpc-core", version = "0.1.2", optional = true } +ras-jsonrpc-core = { path = "../ras-jsonrpc-core", version = "0.2.0", optional = true } # Client dependencies (only referenced by generated client code, gated by the # `client` feature — see `ras-rest-macro`/`ras-file-macro` for the same wiring). ras-transport-core = { path = "../../core/ras-transport-core", version = "0.1.0", optional = true } # Always needed for types -ras-jsonrpc-types = { path = "../ras-jsonrpc-types", version = "0.1.1" } +ras-jsonrpc-types = { path = "../ras-jsonrpc-types", version = "0.2.0" } [dev-dependencies] tokio = { workspace = true } ras-transport-core = { path = "../../core/ras-transport-core", version = "0.1.0", features = ["axum-test"] } tower = { workspace = true } rand = { workspace = true } -ras-identity-session = { path = "../../identity/ras-identity-session", version = "0.2.0" } +ras-identity-session = { path = "../../identity/ras-identity-session", version = "0.3.0" } futures = { workspace = true } # Server dependencies for tests axum = { workspace = true } -ras-jsonrpc-core = { path = "../ras-jsonrpc-core", version = "0.1.2" } -ras-auth-core = { path = "../../core/ras-auth-core", version = "0.1.0" } +ras-jsonrpc-core = { path = "../ras-jsonrpc-core", version = "0.2.0" } +ras-auth-core = { path = "../../core/ras-auth-core", version = "0.2.0" } ras-permission-manifest = { path = "../../specs/ras-permission-manifest", version = "0.1.0" } async-trait = { workspace = true } serde = { workspace = true } diff --git a/crates/rpc/ras-jsonrpc-macro/README.md b/crates/rpc/ras-jsonrpc-macro/README.md index 3337092..3b2e6a9 100644 --- a/crates/rpc/ras-jsonrpc-macro/README.md +++ b/crates/rpc/ras-jsonrpc-macro/README.md @@ -28,9 +28,9 @@ Add this to your `Cargo.toml`: ```toml [dependencies] -ras-jsonrpc-macro = { version = "0.2.0", default-features = false } -ras-jsonrpc-core = { version = "0.1.2", optional = true } -ras-jsonrpc-types = "0.1.1" +ras-jsonrpc-macro = { version = "0.3.0", default-features = false } +ras-jsonrpc-core = { version = "0.2.0", optional = true } +ras-jsonrpc-types = "0.2.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" schemars = "1.0.0-alpha.20" diff --git a/crates/rpc/ras-jsonrpc-macro/src/lib.rs b/crates/rpc/ras-jsonrpc-macro/src/lib.rs index 3297085..0633108 100644 --- a/crates/rpc/ras-jsonrpc-macro/src/lib.rs +++ b/crates/rpc/ras-jsonrpc-macro/src/lib.rs @@ -32,6 +32,29 @@ mod static_hosting; /// }); /// ``` /// +/// # Service options +/// +/// Optional fields alongside `service_name` / `methods`: +/// +/// * `require_json_content_type: ` (default `true`) — reject a request +/// whose `Content-Type` is not `application/json` with `415` before parsing. +/// Requiring `application/json` forces a CORS preflight for cross-origin +/// requests, closing the simple-request CSRF shape. Set to `false` to accept +/// any content type (e.g. for a device client that cannot set the header). +/// * `body_limit: ` (default 2 MiB) — maximum request body size. +/// * `openrpc: true` / `explorer: true` — emit the OpenRPC document and host the +/// API explorer. +/// * `docs_require_auth: ` (default `false`) — when the explorer is +/// enabled, gate the explorer page and `openrpc.json` behind authentication +/// (any authenticated user). Default is public, matching conventional API +/// explorers; the RPC endpoint itself is never gated by this option. +/// * `feature_gated: ` — wrap the server/client in the consumer crate's own +/// `server`/`client` features. +/// +/// A malformed JSON body and authentication/authorization rejections are logged +/// server-side (via `tracing`); a service with any `WITH_PERMISSIONS` method (or +/// a gated explorer) that is built without an auth provider fails `build()`. +/// /// See the tests for further usage examples. #[proc_macro] pub fn jsonrpc_service(input: TokenStream) -> TokenStream { @@ -49,9 +72,21 @@ struct ServiceDefinition { openrpc: Option, explorer: Option, feature_gated: bool, + /// Require an `application/json` request `Content-Type`. Defaults to `true`. + /// Set `require_json_content_type: false` to accept any content type. + require_json_content_type: bool, + /// Maximum request body size in bytes. Defaults to 2 MiB (axum's default). + body_limit: Option, + /// Gate the explorer page and `openrpc.json` behind authentication (any + /// authenticated user). Defaults to `false` — the explorer is public when + /// enabled, matching conventional API-explorer behavior. + docs_require_auth: bool, methods: Vec, } +/// Default maximum JSON body size in bytes (matches axum's default). +const DEFAULT_BODY_LIMIT: usize = 2 * 1024 * 1024; + #[derive(Debug)] enum OpenRpcConfig { Enabled, @@ -173,6 +208,9 @@ impl Parse for ServiceDefinition { let mut openrpc = None; let mut explorer = None; let mut feature_gated = false; + let mut require_json_content_type = true; + let mut body_limit = None; + let mut docs_require_auth = false; // Parse optional fields until we hit "methods" while content.peek(Ident) { @@ -221,6 +259,20 @@ impl Parse for ServiceDefinition { } else if field_name == "feature_gated" { let enabled = content.parse::()?; feature_gated = enabled.value(); + } else if field_name == "require_json_content_type" { + let enabled = content.parse::()?; + require_json_content_type = enabled.value(); + } else if field_name == "body_limit" { + let limit = content.parse::()?; + body_limit = Some(limit.base10_parse::()?); + } else if field_name == "docs_require_auth" { + let enabled = content.parse::()?; + docs_require_auth = enabled.value(); + } else { + return Err(syn::Error::new( + field_name.span(), + format!("Unknown field: {field_name}"), + )); } let _ = content.parse::()?; @@ -249,6 +301,9 @@ impl Parse for ServiceDefinition { openrpc, explorer, feature_gated, + require_json_content_type, + body_limit, + docs_require_auth, methods, }) } @@ -305,6 +360,18 @@ impl Parse for MethodDefinition { permission_groups.push(group); } + if permission_groups.len() > 1 + && permission_groups.iter().any(|group| group.is_empty()) + { + return Err(syn::Error::new( + auth_ident.span(), + "an empty permission group is only valid as the entire requirement \ + (WITH_PERMISSIONS([]), meaning any authenticated user); mixing an \ + empty group with non-empty groups would silently grant access to any \ + authenticated user", + )); + } + AuthRequirement::WithPermissions(permission_groups) } _ => { @@ -559,20 +626,138 @@ fn generate_service_code(service_def: ServiceDefinition) -> syn::Result proc_macro2::TokenStream { let service_name = &service_def.service_name; + let service_name_str = service_name.to_string(); let service_trait_name = quote::format_ident!("{}Trait", service_name); let builder_name = quote::format_ident!("{}Builder", service_name); - // Generate explorer route integration if enabled - let explorer_route_integration = - if service_def.explorer.is_some() && service_def.openrpc.is_some() { - let service_name_str = service_name.to_string(); - let service_name_lower = service_name_str.to_lowercase(); - let explorer_routes_fn_str = [&service_name_lower, "_explorer_routes"].concat(); - let explorer_routes_fn = syn::Ident::new(&explorer_routes_fn_str, service_name.span()); - quote! { router = router.merge(#explorer_routes_fn(&base_url)); } + let explorer_enabled = service_def.explorer.is_some() && service_def.openrpc.is_some(); + + // Content-Type gate (#1): reject a non-`application/json` body with 415 before + // parsing. Requiring `application/json` forces a CORS preflight for + // cross-origin requests, closing the simple-request CSRF shape. + let content_type_gate = if service_def.require_json_content_type { + quote! { + { + let __ras_content_type_ok = headers + .get(axum::http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| { + value + .split(';') + .next() + .unwrap_or("") + .trim() + .eq_ignore_ascii_case("application/json") + }) + .unwrap_or(false); + if !__ras_content_type_ok { + ras_jsonrpc_core::tracing::warn!( + "rejected JSON-RPC request: Content-Type is not application/json" + ); + return ( + axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE, + [("Content-Type", "application/json")], + serde_json::to_string(&ras_jsonrpc_types::JsonRpcResponse::error( + ras_jsonrpc_types::JsonRpcError::invalid_request(), + None, + )) + .unwrap_or_else(|_| "{}".to_string()), + ); + } + } + } + } else { + quote! {} + }; + + // Body-size cap (#6a): apply as a DefaultBodyLimit layer so an over-limit body + // is rejected by the extractor before the handler runs. + let body_limit_value = service_def.body_limit.unwrap_or(DEFAULT_BODY_LIMIT); + + // Startup assertion (#6c): a service with any WITH_PERMISSIONS method (or a + // gated explorer) needs an auth provider, else every such call silently fails + // authentication at runtime. Fail the build instead. + let any_route_requires_auth = service_def + .methods + .iter() + .any(|method| matches!(method.auth, AuthRequirement::WithPermissions(_))) + || (explorer_enabled && service_def.docs_require_auth); + let provider_check = if any_route_requires_auth { + quote! { + if self.auth_provider.is_none() { + return Err(concat!( + "JSON-RPC service `", + #service_name_str, + "` has methods requiring authorization (WITH_PERMISSIONS) but no ", + "auth_provider was configured; call .auth_provider(...) before build()" + ) + .to_string()); + } + } + } else { + quote! {} + }; + + // Explorer route integration (#4): merge the explorer/openrpc routes, gated + // behind authentication when `docs_require_auth` is set. The default explorer + // routes function stays public and unchanged; gating is applied here (where + // the built service, and thus the auth config, is in scope) via a layer. + let explorer_route_integration = if explorer_enabled { + let service_name_lower = service_name_str.to_lowercase(); + let explorer_routes_fn_str = [&service_name_lower, "_explorer_routes"].concat(); + let explorer_routes_fn = syn::Ident::new(&explorer_routes_fn_str, service_name.span()); + if service_def.docs_require_auth { + quote! { + { + let __ras_docs_service = service.clone(); + // `route_layer` (not `layer`) so the gate runs only for the + // explorer/openrpc routes that actually match — an unrelated + // path 404s without the middleware, and the RPC endpoint + // (a separate route) is never gated. + let __ras_explorer = #explorer_routes_fn(&base_url).route_layer( + axum::middleware::from_fn(move |__ras_req: axum::extract::Request, __ras_next: axum::middleware::Next| { + let __ras_docs_service = __ras_docs_service.clone(); + async move { + use axum::response::IntoResponse; + let __ras_headers = __ras_req.headers().clone(); + let __ras_empty: Vec> = Vec::new(); + match ras_jsonrpc_core::authorize_request( + "GET", + &__ras_headers, + &__ras_docs_service.auth_transport, + __ras_docs_service.auth_provider.as_deref(), + &__ras_empty, + ).await { + Ok(_) => __ras_next.run(__ras_req).await, + Err(__ras_err) => { + let __ras_status = match __ras_err { + ras_jsonrpc_core::AuthorizeError::CsrfValidationFailed + | ras_jsonrpc_core::AuthorizeError::InsufficientPermissions(_) => + axum::http::StatusCode::FORBIDDEN, + ras_jsonrpc_core::AuthorizeError::NoAuthProvider => + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + _ => axum::http::StatusCode::UNAUTHORIZED, + }; + ras_jsonrpc_core::tracing::warn!(status = __ras_status.as_u16(), "explorer request rejected"); + ( + __ras_status, + [("Content-Type", "application/json")], + serde_json::json!({ "error": "Authentication required" }).to_string(), + ).into_response() + } + } + } + }) + ); + router = router.merge(__ras_explorer); + } + } } else { - quote! {} - }; + quote! { router = router.merge(#explorer_routes_fn(&base_url)); } + } + } else { + quote! {} + }; // Generate trait methods let trait_methods = service_def.methods.iter().map(|method| { @@ -672,8 +857,15 @@ fn generate_server_code(service_def: &ServiceDefinition) -> proc_macro2::TokenSt } /// Enable cookie authentication alongside bearer tokens. + /// + /// Installs a default double-submit CSRF config when none is set, + /// because cookie credentials are CSRF-exploitable on unsafe methods. + /// Override with `csrf_protection`. pub fn auth_cookie(mut self, cookie: ras_jsonrpc_core::AuthCookieConfig) -> Self { self.auth_transport.cookie = Some(cookie); + if self.auth_transport.csrf.is_none() { + self.auth_transport.csrf = Some(ras_jsonrpc_core::CsrfConfig::default()); + } self } @@ -721,12 +913,21 @@ fn generate_server_code(service_def: &ServiceDefinition) -> proc_macro2::TokenSt .validate() .map_err(|err| err.to_string())?; + #provider_check + let base_url = self.base_url.clone(); let service = std::sync::Arc::new(self); - let rpc_handler = axum::routing::post(move |headers: axum::http::HeaderMap, body: String| { + let rpc_handler = axum::routing::post({ + // Clone into the handler so the outer `service` survives for + // the (optional) docs auth gate below. + let service = service.clone(); + move |headers: axum::http::HeaderMap, body: String| { let service = service.clone(); async move { + // Reject a non-`application/json` body with 415 before parsing. + #content_type_gate + let response = service.handle_request(headers, body).await; // Determine HTTP status code based on JSON-RPC error code @@ -744,12 +945,26 @@ fn generate_server_code(service_def: &ServiceDefinition) -> proc_macro2::TokenSt axum::http::StatusCode::OK }; + // Rejections otherwise bypass the usage/duration trackers + // (which run mid-dispatch); log auth/CSRF/permission + // rejections here so a bad-credential caller is observable. + if status_code != axum::http::StatusCode::OK { + if let Some(ref error) = response.error { + ras_jsonrpc_core::tracing::warn!( + status = status_code.as_u16(), + code = error.code, + "JSON-RPC request rejected" + ); + } + } + ( status_code, [("Content-Type", "application/json")], serde_json::to_string(&response).unwrap_or_else(|_| "{}".to_string()) ) } + } }); let mut router = axum::Router::new(); @@ -757,6 +972,9 @@ fn generate_server_code(service_def: &ServiceDefinition) -> proc_macro2::TokenSt // Add the JSON-RPC endpoint router = router.route(&base_url, rpc_handler); + // Bound the request body size for the JSON-RPC endpoint (#6a). + router = router.layer(axum::extract::DefaultBodyLimit::max(#body_limit_value)); + // Include explorer routes if explorer is enabled #explorer_route_integration @@ -767,7 +985,17 @@ fn generate_server_code(service_def: &ServiceDefinition) -> proc_macro2::TokenSt // Parse JSON-RPC request let request: ras_jsonrpc_types::JsonRpcRequest = match serde_json::from_str(&body) { Ok(req) => req, - Err(_) => return ras_jsonrpc_types::JsonRpcResponse::error(ras_jsonrpc_types::JsonRpcError::parse_error(), None), + Err(__ras_json_err) => { + // Log the classification and location so the reason is + // recoverable server-side; never log the rejected value. + ras_jsonrpc_core::tracing::warn!( + category = ?__ras_json_err.classify(), + line = __ras_json_err.line(), + column = __ras_json_err.column(), + "rejected JSON-RPC request: malformed JSON body" + ); + return ras_jsonrpc_types::JsonRpcResponse::error(ras_jsonrpc_types::JsonRpcError::parse_error(), None); + } }; let request_id = request.id.clone(); @@ -907,12 +1135,14 @@ fn jsonrpc_auth_check_code( let required_permission_groups: Vec> = #permission_groups_code; let provider = self.auth_provider.as_ref().expect("auth provider required for WITH_PERMISSIONS methods"); if let Err(error) = ras_jsonrpc_core::check_permission_groups(provider.as_ref(), user, &required_permission_groups) { - let (required, has) = match error { - ras_jsonrpc_core::AuthError::InsufficientPermissions { required, has } => (required, has), - _ => (Vec::new(), Vec::new()), + // Only `required` is surfaced to the client; the caller's + // full grant set (`has`) stays server-side (M1). + let required = match error { + ras_jsonrpc_core::AuthError::InsufficientPermissions { required, .. } => required, + _ => Vec::new(), }; return ras_jsonrpc_types::JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::insufficient_permissions(required, has), + ras_jsonrpc_types::JsonRpcError::insufficient_permissions(required), request.id.clone() ); } diff --git a/crates/rpc/ras-jsonrpc-macro/tests/error_sanitization_test.rs b/crates/rpc/ras-jsonrpc-macro/tests/error_sanitization_test.rs index 7216569..3c16dec 100644 --- a/crates/rpc/ras-jsonrpc-macro/tests/error_sanitization_test.rs +++ b/crates/rpc/ras-jsonrpc-macro/tests/error_sanitization_test.rs @@ -45,9 +45,41 @@ mod tests { } } + // Minimal auth provider so the WITH_PERMISSIONS method can be built. It + // rejects every credential, which is sufficient for the sanitization tests. + #[derive(Clone)] + struct RejectingAuth; + + impl ras_jsonrpc_core::AuthProvider for RejectingAuth { + fn authenticate( + &self, + _authorization: String, + ) -> std::pin::Pin< + Box< + dyn std::future::Future< + Output = Result< + ras_jsonrpc_core::AuthenticatedUser, + ras_jsonrpc_core::AuthError, + >, + > + Send, + >, + > { + Box::pin(async move { Err(ras_jsonrpc_core::AuthError::InvalidToken) }) + } + + fn check_permissions( + &self, + _user: &ras_jsonrpc_core::AuthenticatedUser, + _required_permissions: &[String], + ) -> Result<(), ras_jsonrpc_core::AuthError> { + Ok(()) + } + } + fn setup_test_server() -> axum_test::TestServer { let router = TestServiceBuilder::new(TestServiceImpl) .base_url("/api/rpc") + .auth_provider(RejectingAuth) .build() .expect("Failed to build router"); diff --git a/crates/rpc/ras-jsonrpc-macro/tests/http_integration.rs b/crates/rpc/ras-jsonrpc-macro/tests/http_integration.rs index 3872439..00dcf39 100644 --- a/crates/rpc/ras-jsonrpc-macro/tests/http_integration.rs +++ b/crates/rpc/ras-jsonrpc-macro/tests/http_integration.rs @@ -1,6 +1,6 @@ use rand::Rng; use ras_jsonrpc_core::{ - AuthCookieConfig, AuthError, AuthFuture, AuthProvider, AuthenticatedUser, CsrfConfig, + AuthCookieConfig, AuthError, AuthFuture, AuthProvider, AuthenticatedUser, }; use ras_jsonrpc_macro::jsonrpc_service; use serde::{Deserialize, Serialize}; @@ -265,16 +265,14 @@ fn create_test_server() -> axum_test::TestServer { .unwrap() } -fn create_cookie_test_server(csrf: bool) -> axum_test::TestServer { - let mut builder = TestServiceBuilder::new(TestServiceImpl) +// `auth_cookie` now always installs a default double-submit CSRF config (H2), +// so there is no cookie-without-CSRF server to construct. +fn create_cookie_test_server() -> axum_test::TestServer { + let builder = TestServiceBuilder::new(TestServiceImpl) .base_url("/rpc") .auth_provider(TestAuthProvider::new()) .auth_cookie(AuthCookieConfig::default()); - if csrf { - builder = builder.csrf_protection(CsrfConfig::default()); - } - let app = builder.build().expect("Failed to build app"); axum_test::TestServer::builder() .mock_transport() @@ -428,7 +426,7 @@ async fn test_authentication_required_methods() { #[tokio::test] async fn test_cookie_auth_coexists_with_bearer_tokens() { - let server = create_cookie_test_server(false); + let server = create_cookie_test_server(); let request_body = json!({ "jsonrpc": "2.0", "method": "get_user_info", @@ -436,9 +434,14 @@ async fn test_cookie_auth_coexists_with_bearer_tokens() { "id": 1 }); + // Cookie auth on a POST now requires the double-submit CSRF header (H2). let response: Value = server .post("/rpc") - .add_header("Cookie", "__Host-ras-session=valid-user-token") + .add_header( + "Cookie", + "__Host-ras-session=valid-user-token; __Host-ras-csrf=csrf-token", + ) + .add_header("x-ras-csrf", "csrf-token") .json(&request_body) .await .json(); @@ -469,7 +472,7 @@ async fn test_cookie_auth_coexists_with_bearer_tokens() { #[tokio::test] async fn test_cookie_auth_csrf_guard_for_jsonrpc_posts() { - let server = create_cookie_test_server(true); + let server = create_cookie_test_server(); let request_body = json!({ "jsonrpc": "2.0", "method": "get_user_info", diff --git a/crates/rpc/ras-jsonrpc-macro/tests/http_status_codes_test.rs b/crates/rpc/ras-jsonrpc-macro/tests/http_status_codes_test.rs index 7db68f1..91fe277 100644 --- a/crates/rpc/ras-jsonrpc-macro/tests/http_status_codes_test.rs +++ b/crates/rpc/ras-jsonrpc-macro/tests/http_status_codes_test.rs @@ -83,6 +83,16 @@ impl AuthProvider for MockAuthProvider { metadata: None, }) } + "user-hidden-token" => { + let mut permissions = HashSet::new(); + permissions.insert("user".to_string()); + permissions.insert("hidden:internal".to_string()); + Ok(AuthenticatedUser { + user_id: "user2".to_string(), + permissions, + metadata: None, + }) + } "expired-token" => Err(AuthError::TokenExpired), _ => Err(AuthError::InvalidToken), } @@ -175,6 +185,38 @@ async fn test_insufficient_permissions_returns_403() { assert_eq!(json["error"]["code"], -32002); // INSUFFICIENT_PERMISSIONS } +#[tokio::test] +async fn test_403_does_not_leak_callers_permission_set() { + let app = test_app(); + + // A user holding an internal permission probes an admin method. The 403 must + // not echo back the caller's grant set (M1) — `hidden:internal` must not + // appear anywhere in the response body. + let response = make_jsonrpc_request( + app.clone(), + "admin_method", + serde_json::json!({"value": "test"}), + Some("Bearer user-hidden-token"), + ) + .await; + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let body_str = String::from_utf8_lossy(&body); + assert!( + !body_str.contains("hidden:internal"), + "403 body leaked the caller's permission set: {body_str}" + ); + + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["error"]["code"], -32002); + // `required` may be advertised; `has` must be absent. + assert!(json["error"]["data"].get("has").is_none()); +} + #[tokio::test] async fn test_invalid_token_returns_401() { let app = test_app(); diff --git a/crates/rpc/ras-jsonrpc-macro/tests/xm_feedback_parity_test.rs b/crates/rpc/ras-jsonrpc-macro/tests/xm_feedback_parity_test.rs new file mode 100644 index 0000000..6e0a5d4 --- /dev/null +++ b/crates/rpc/ras-jsonrpc-macro/tests/xm_feedback_parity_test.rs @@ -0,0 +1,301 @@ +//! Regression tests for the `jsonrpc_service!` hardening that brings it to parity +//! with the `rest_service!` changes prompted by the XM device-integration feedback: +//! +//! * Content-Type enforcement (strict `application/json`, opt-out). +//! * Service-level `body_limit`. +//! * `docs_require_auth` gate on the explorer / openrpc routes. +//! * `build()` fails when a permissioned service has no auth provider. + +use axum_test::TestServer; +use ras_jsonrpc_core::{AuthError, AuthProvider, AuthenticatedUser}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::collections::HashSet; +use std::pin::Pin; + +#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] +pub struct PingRequest { + value: String, +} + +#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] +pub struct PingResponse { + echo: String, +} + +#[derive(Clone)] +struct MockAuth; + +impl AuthProvider for MockAuth { + fn authenticate( + &self, + authorization: String, + ) -> Pin> + Send + '_>> + { + Box::pin(async move { + if authorization == "admin-token" { + let mut permissions = HashSet::new(); + permissions.insert("admin".to_string()); + Ok(AuthenticatedUser { + user_id: "admin".to_string(), + permissions, + metadata: None, + }) + } else { + Err(AuthError::InvalidToken) + } + }) + } + + fn check_permissions( + &self, + user: &AuthenticatedUser, + required_permissions: &[String], + ) -> Result<(), AuthError> { + if required_permissions + .iter() + .all(|perm| user.permissions.contains(perm)) + { + Ok(()) + } else { + Err(AuthError::InsufficientPermissions { + required: required_permissions.to_vec(), + has: user.permissions.iter().cloned().collect(), + }) + } + } +} + +// --------------------------------------------------------------------------- +// Content-Type enforcement (strict default) + opt-out +// --------------------------------------------------------------------------- + +ras_jsonrpc_macro::jsonrpc_service!({ + service_name: StrictRpc, + methods: [ + UNAUTHORIZED ping(PingRequest) -> PingResponse, + ] +}); + +struct StrictRpcImpl; + +impl StrictRpcTrait for StrictRpcImpl { + async fn ping( + &self, + request: PingRequest, + ) -> Result> { + Ok(PingResponse { echo: request.value }) + } +} + +ras_jsonrpc_macro::jsonrpc_service!({ + service_name: LenientRpc, + require_json_content_type: false, + methods: [ + UNAUTHORIZED ping(PingRequest) -> PingResponse, + ] +}); + +struct LenientRpcImpl; + +impl LenientRpcTrait for LenientRpcImpl { + async fn ping( + &self, + request: PingRequest, + ) -> Result> { + Ok(PingResponse { echo: request.value }) + } +} + +fn rpc_envelope() -> Value { + json!({ + "jsonrpc": "2.0", + "method": "ping", + "params": { "value": "hi" }, + "id": 1, + }) +} + +#[tokio::test] +async fn strict_rpc_requires_json_content_type() { + let router = StrictRpcBuilder::new(StrictRpcImpl).build().unwrap(); + let server = TestServer::builder().mock_transport().build(router).unwrap(); + + // application/json → accepted. + let ok = server.post("/rpc").json(&rpc_envelope()).await; + assert_eq!(ok.status_code().as_u16(), 200); + + // text/plain carrying a valid JSON-RPC envelope → 415, before dispatch. + let rejected = server + .post("/rpc") + .text(rpc_envelope().to_string()) + .content_type("text/plain") + .await; + assert_eq!(rejected.status_code().as_u16(), 415); +} + +#[tokio::test] +async fn lenient_rpc_accepts_any_content_type() { + let router = LenientRpcBuilder::new(LenientRpcImpl).build().unwrap(); + let server = TestServer::builder().mock_transport().build(router).unwrap(); + + let ok = server + .post("/rpc") + .text(rpc_envelope().to_string()) + .content_type("text/plain") + .await; + assert_eq!(ok.status_code().as_u16(), 200); +} + +// --------------------------------------------------------------------------- +// Service-level body_limit +// --------------------------------------------------------------------------- + +ras_jsonrpc_macro::jsonrpc_service!({ + service_name: TinyRpc, + body_limit: 64, + methods: [ + UNAUTHORIZED ping(PingRequest) -> PingResponse, + ] +}); + +struct TinyRpcImpl; + +impl TinyRpcTrait for TinyRpcImpl { + async fn ping( + &self, + request: PingRequest, + ) -> Result> { + Ok(PingResponse { echo: request.value }) + } +} + +#[tokio::test] +async fn rpc_body_limit_rejects_oversized_body() { + let router = TinyRpcBuilder::new(TinyRpcImpl).build().unwrap(); + let server = TestServer::builder().mock_transport().build(router).unwrap(); + + let big = json!({ + "jsonrpc": "2.0", + "method": "ping", + "params": { "value": "x".repeat(256) }, + "id": 1, + }); + let response = server.post("/rpc").json(&big).await; + assert_eq!(response.status_code().as_u16(), 413); +} + +// --------------------------------------------------------------------------- +// build() fails when a permissioned service has no auth provider +// --------------------------------------------------------------------------- + +ras_jsonrpc_macro::jsonrpc_service!({ + service_name: NeedsProviderRpc, + methods: [ + WITH_PERMISSIONS(["admin"]) secret(PingRequest) -> PingResponse, + ] +}); + +struct NeedsProviderRpcImpl; + +impl NeedsProviderRpcTrait for NeedsProviderRpcImpl { + async fn secret( + &self, + _user: &AuthenticatedUser, + request: PingRequest, + ) -> Result> { + Ok(PingResponse { echo: request.value }) + } +} + +#[test] +fn build_errors_when_permissioned_service_has_no_provider() { + let result = NeedsProviderRpcBuilder::new(NeedsProviderRpcImpl).build(); + let err = result.expect_err("build should fail without an auth provider"); + assert!( + err.contains("auth_provider"), + "error should mention auth_provider, got: {err}" + ); +} + +// --------------------------------------------------------------------------- +// docs_require_auth gate on the explorer / openrpc routes +// --------------------------------------------------------------------------- + +ras_jsonrpc_macro::jsonrpc_service!({ + service_name: GatedDocsRpc, + openrpc: true, + explorer: true, + docs_require_auth: true, + methods: [ + UNAUTHORIZED ping(PingRequest) -> PingResponse, + WITH_PERMISSIONS(["admin"]) secret(PingRequest) -> PingResponse, + ] +}); + +struct GatedDocsRpcImpl; + +impl GatedDocsRpcTrait for GatedDocsRpcImpl { + async fn ping( + &self, + request: PingRequest, + ) -> Result> { + Ok(PingResponse { echo: request.value }) + } + + async fn secret( + &self, + _user: &AuthenticatedUser, + request: PingRequest, + ) -> Result> { + Ok(PingResponse { echo: request.value }) + } +} + +#[tokio::test] +async fn gated_explorer_requires_authentication() { + let router = GatedDocsRpcBuilder::new(GatedDocsRpcImpl) + .auth_provider(MockAuth) + .build() + .unwrap(); + let server = TestServer::builder().mock_transport().build(router).unwrap(); + + // The explorer is served relative to the RPC base path ("/rpc"). + // Unauthenticated explorer + openrpc → rejected. + assert_eq!(server.get("/rpc/explorer").await.status_code().as_u16(), 401); + assert_eq!( + server + .get("/rpc/explorer/openrpc.json") + .await + .status_code() + .as_u16(), + 401 + ); + + // With a valid credential → served. + let explorer = server + .get("/rpc/explorer") + .authorization_bearer("admin-token") + .await; + assert_eq!(explorer.status_code().as_u16(), 200); + let spec = server + .get("/rpc/explorer/openrpc.json") + .authorization_bearer("admin-token") + .await; + assert_eq!(spec.status_code().as_u16(), 200); + + // The docs gate must NOT leak onto the RPC endpoint: an unauthenticated + // UNAUTHORIZED method call still succeeds. + let ping = server + .post("/rpc") + .json(&json!({ + "jsonrpc": "2.0", + "method": "ping", + "params": { "value": "hi" }, + "id": 1, + })) + .await; + assert_eq!(ping.status_code().as_u16(), 200); + let body: Value = ping.json(); + assert_eq!(body["result"]["echo"], "hi"); +} diff --git a/crates/rpc/ras-jsonrpc-types/Cargo.toml b/crates/rpc/ras-jsonrpc-types/Cargo.toml index fbfb36c..6b739a2 100644 --- a/crates/rpc/ras-jsonrpc-types/Cargo.toml +++ b/crates/rpc/ras-jsonrpc-types/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ras-jsonrpc-types" -version = "0.1.1" +version = "0.2.0" edition = "2024" rust-version = "1.88" description = "JSON-RPC 2.0 protocol types and utilities" diff --git a/crates/rpc/ras-jsonrpc-types/README.md b/crates/rpc/ras-jsonrpc-types/README.md index c4f2993..8c6d73c 100644 --- a/crates/rpc/ras-jsonrpc-types/README.md +++ b/crates/rpc/ras-jsonrpc-types/README.md @@ -20,7 +20,7 @@ Add this to your `Cargo.toml`: ```toml [dependencies] -ras-jsonrpc-types = "0.1.1" +ras-jsonrpc-types = "0.2.0" ``` ### Basic Types @@ -62,9 +62,10 @@ let internal_error = JsonRpcError::internal_error("Server error".to_string()); // Custom authentication errors let auth_required = JsonRpcError::authentication_required(); +// Only the *required* permissions are surfaced; the caller's own grant set is +// deliberately not echoed back in the error data. let insufficient_perms = JsonRpcError::insufficient_permissions( vec!["admin".to_string()], - vec!["user".to_string()] ); let token_expired = JsonRpcError::token_expired(); ``` diff --git a/crates/rpc/ras-jsonrpc-types/src/lib.rs b/crates/rpc/ras-jsonrpc-types/src/lib.rs index b8bef45..131e14b 100644 --- a/crates/rpc/ras-jsonrpc-types/src/lib.rs +++ b/crates/rpc/ras-jsonrpc-types/src/lib.rs @@ -185,13 +185,17 @@ impl JsonRpcError { } /// Creates an insufficient permissions error. - pub fn insufficient_permissions(required: Vec, has: Vec) -> Self { + /// + /// Only `required` is included in the error data so clients know what to + /// request. The caller's actual grant set is deliberately never echoed back + /// — surfacing it would let any authenticated user enumerate their own (and + /// internal) permission names by probing privileged methods (M1). + pub fn insufficient_permissions(required: Vec) -> Self { Self::new( error_codes::INSUFFICIENT_PERMISSIONS, "Insufficient permissions".to_string(), Some(serde_json::json!({ "required": required, - "has": has })), ) } @@ -275,12 +279,13 @@ mod tests { } #[test] - fn insufficient_permissions_carries_data() { - let err = JsonRpcError::insufficient_permissions(vec!["admin".into()], vec!["user".into()]); + fn insufficient_permissions_carries_required_but_not_caller_grants() { + let err = JsonRpcError::insufficient_permissions(vec!["admin".into()]); assert_eq!(err.code, error_codes::INSUFFICIENT_PERMISSIONS); let data = err.data.unwrap(); assert_eq!(data["required"], serde_json::json!(["admin"])); - assert_eq!(data["has"], serde_json::json!(["user"])); + // The caller's grant set must never be surfaced (M1). + assert!(data.get("has").is_none()); } #[test] diff --git a/documentation/src/identity-and-sessions.md b/documentation/src/identity-and-sessions.md index bb6f74a..b8a8d48 100644 --- a/documentation/src/identity-and-sessions.md +++ b/documentation/src/identity-and-sessions.md @@ -46,9 +46,16 @@ specific permission. ## Secure Browser Sessions Browser-facing services can use secure `HttpOnly` cookies instead of manually -placing bearer tokens in JavaScript. The same generated builders support cookie -auth transport and double-submit CSRF protection for unsafe cookie-authenticated -requests. +placing bearer tokens in JavaScript. Cookie auth is not two independent knobs: +because the browser attaches cookies automatically, cookie credentials are +**always** paired with CSRF protection. Calling `.auth_cookie(...)` installs a +default double-submit `CsrfConfig` for you, and a transport that enables cookies +without a CSRF config fails to `build()`. Override the default with +`.csrf_protection(...)` if you need a session-bound token or a custom header, but +there is deliberately no builder path to cookie auth without CSRF. + +CSRF is enforced only for cookie credentials on unsafe methods (`POST`, `PUT`, +`PATCH`, `DELETE`). Bearer tokens and safe methods remain exempt. See the OAuth2 example in [examples/oauth2-demo](https://github.com/JedimEmO/rust-api-stack/tree/master/examples/oauth2-demo). diff --git a/documentation/src/macros/bidirectional-jsonrpc-service.md b/documentation/src/macros/bidirectional-jsonrpc-service.md index 20c3202..21a613f 100644 --- a/documentation/src/macros/bidirectional-jsonrpc-service.md +++ b/documentation/src/macros/bidirectional-jsonrpc-service.md @@ -12,12 +12,12 @@ request support. async-trait = "0.1" serde = { version = "1", features = ["derive"] } serde_json = "1" -ras-auth-core = "0.1.0" -ras-jsonrpc-types = "0.1.1" -ras-jsonrpc-bidirectional-types = "0.1.0" -ras-jsonrpc-bidirectional-macro = { version = "0.1.0", default-features = false } -ras-jsonrpc-bidirectional-server = { version = "0.1.0", optional = true } -ras-jsonrpc-bidirectional-client = { version = "0.1.0", optional = true } +ras-auth-core = "0.2.0" +ras-jsonrpc-types = "0.2.0" +ras-jsonrpc-bidirectional-types = "0.2.0" +ras-jsonrpc-bidirectional-macro = { version = "0.2.0", default-features = false } +ras-jsonrpc-bidirectional-server = { version = "0.2.0", optional = true } +ras-jsonrpc-bidirectional-client = { version = "0.2.0", optional = true } [features] default = [] diff --git a/documentation/src/macros/file-service.md b/documentation/src/macros/file-service.md index 63ed605..68f8d92 100644 --- a/documentation/src/macros/file-service.md +++ b/documentation/src/macros/file-service.md @@ -13,9 +13,9 @@ macro crate features: ```toml [dependencies] -ras-file-macro = { version = "0.1.0", default-features = false } -ras-file-core = { version = "0.1.0", optional = true } -ras-auth-core = { version = "0.1.0", optional = true } +ras-file-macro = { version = "0.2.0", default-features = false } +ras-file-core = { version = "0.2.0", optional = true } +ras-auth-core = { version = "0.2.0", optional = true } serde = { version = "1.0", features = ["derive"] } async-trait = { version = "0.1", optional = true } ras-transport-core = { version = "0.1.0", optional = true } diff --git a/documentation/src/macros/jsonrpc-service.md b/documentation/src/macros/jsonrpc-service.md index 22a33b5..e918a26 100644 --- a/documentation/src/macros/jsonrpc-service.md +++ b/documentation/src/macros/jsonrpc-service.md @@ -13,15 +13,15 @@ refer to. ```toml [dependencies] -ras-jsonrpc-macro = { version = "0.2.0", default-features = false } -ras-jsonrpc-types = "0.1.1" +ras-jsonrpc-macro = { version = "0.3.0", default-features = false } +ras-jsonrpc-types = "0.2.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" schemars = "1.0.0-alpha.20" ras-transport-core = { version = "0.1.0", optional = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -ras-jsonrpc-core = { version = "0.1.2", optional = true } +ras-jsonrpc-core = { version = "0.2.0", optional = true } axum = { version = "0.8", optional = true } tokio = { version = "1.0", features = ["full"], optional = true } @@ -82,6 +82,38 @@ sets an explicit `wire` name. The auth requirement is one of `UNAUTHORIZED`, `OPTIONAL_AUTH`, or `WITH_PERMISSIONS([...])`; see [Auth In The API Contract](../auth-in-api-contract.md). +## Service Options + +Optional service-level fields (alongside `service_name` / `methods`): + +| Option | Default | Meaning | +| --- | --- | --- | +| `require_json_content_type: ` | `true` | Reject a non-`application/json` request with `415` before parsing. | +| `body_limit: ` | `2 * 1024 * 1024` | Maximum request body size. | +| `openrpc: true` / `explorer: true` | off | Emit the OpenRPC document and host the API explorer. | +| `docs_require_auth: ` | `false` | Gate the explorer page and `openrpc.json` behind authentication (any authenticated user). | +| `feature_gated: ` | `false` | Wrap the server/client in the consumer crate's own `server`/`client` features. | + +The JSON-RPC endpoint always uses `application/json`; the default +`require_json_content_type` check rejects a cross-origin `text/plain` POST (a +CORS-safelisted content type that skips preflight) before the body is parsed. Set +it to `false` only for clients that cannot send the header. + +> **Note:** the explorer and `openrpc.json` are served **without** authentication +> by default when enabled, exposing your method names and schemas. Set +> `docs_require_auth: true` to gate them (the RPC endpoint itself is never gated +> by this option), or leave the explorer disabled in production. +> +> Because a browser top-level navigation cannot send an `Authorization` header, +> the gated explorer is only reachable in a browser under **cookie** auth +> (`.auth_cookie(...)`); on a bearer-only transport it is reachable only by a +> programmatic client that sets the header. + +A malformed JSON body and authentication/authorization rejections are logged +server-side via `tracing`. A service that declares any `WITH_PERMISSIONS` method +(or a gated explorer) but is built without `.auth_provider(...)` fails `build()` +with a clear error instead of rejecting every such call at runtime. + ## Implement The Generated Trait Protected (`WITH_PERMISSIONS`) methods receive `&AuthenticatedUser` before their diff --git a/documentation/src/macros/rest-service.md b/documentation/src/macros/rest-service.md index 3327f73..855cd13 100644 --- a/documentation/src/macros/rest-service.md +++ b/documentation/src/macros/rest-service.md @@ -8,7 +8,7 @@ explorer. ```toml [dependencies] -ras-rest-macro = { version = "0.2.1", default-features = false } +ras-rest-macro = { version = "0.3.0", default-features = false } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" schemars = "1.0.0-alpha.20" @@ -16,8 +16,8 @@ async-trait = { version = "0.1", optional = true } ras-transport-core = { version = "0.1.0", optional = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -ras-rest-core = { version = "0.1.1", optional = true } -ras-auth-core = { version = "0.1.0", optional = true } +ras-rest-core = { version = "0.2.0", optional = true } +ras-auth-core = { version = "0.2.0", optional = true } axum = { version = "0.8", optional = true } axum-extra = { version = "0.10", features = ["query"], optional = true } tokio = { version = "1.0", features = ["full"], optional = true } @@ -100,6 +100,95 @@ Supported methods are `GET`, `POST`, `PUT`, `DELETE`, and `PATCH`. handler receives a `ras_auth_core::Caller` as its first argument: the route is public, but identifies the caller when a valid credential is present. +## Request Bodies And `Content-Type` + +Endpoints that declare a body read and JSON-decode it **after** the +auth/CSRF/permission checks succeed, so unauthenticated callers cannot make the +server buffer or parse payloads. By default a request whose `Content-Type` is +not `application/json` (parameters such as `; charset=utf-8` are allowed) is +rejected with `415 Unsupported Media Type` before the body is read. Requiring +`application/json` forces a CORS preflight for cross-origin requests, closing the +simple-request CSRF shape (a cross-origin `text/plain` POST). Malformed JSON is +logged (category + line/column, never the value) and answered with `400`; a body +over the limit is `413`, distinct from an unreadable stream (`400`). + +To accept any content type — for example a device client that cannot set the +header — opt out at the service level with `require_json_content_type: false`. + +The gate only applies to endpoints that declare a request body. A bodiless +mutating endpoint (e.g. `POST logout() -> ()`) has no body to type-check and is +not gated, so its CSRF protection comes from the auth transport: a bearer token +is not ambient, and cookie auth carries a mandatory CSRF header (a non-safelisted +header that itself forces a preflight). + +## Service And Endpoint Options + +Service-level options (alongside `service_name` / `base_path` / `endpoints`): + +| Option | Default | Meaning | +| --- | --- | --- | +| `body_limit: ` | `2 * 1024 * 1024` | Maximum request body size. | +| `require_json_content_type: ` | `true` | Enforce `application/json` on bodied endpoints. | +| `serve_docs: ` / `docs_path: "..."` | `false` / `/docs` | Host the API explorer and `openapi.json`. | +| `docs_require_auth: ` | `false` | Gate the docs page and `openapi.json` behind authentication (any authenticated user). | +| `feature_gated: ` | `false` | Wrap the server/client in the consumer crate's own `server`/`client` features. | + +> **Note:** when `serve_docs` is enabled the docs page and `openapi.json` are +> served **without** authentication by default, exposing your method names, +> schemas, and permission requirements. Set `docs_require_auth: true` to gate +> them, or disable `serve_docs` in production. +> +> `docs_require_auth` gates the whole explorer (page + spec) with the same +> credential check as your endpoints. Because a browser top-level navigation +> cannot send an `Authorization` header, the gated docs are only reachable in a +> browser under **cookie** auth (`.auth_cookie(...)`); on a bearer-only transport +> they are reachable only by a programmatic client that sets the header. Use it +> when the docs live behind cookie auth or should be hidden from browsers +> entirely. + +Per-endpoint options go in a trailing `{ ... }` block after the response type: + +```rust,ignore +// 16 KiB cap and access to request headers for just this endpoint. +POST WITH_PERMISSIONS(["admin"]) devices/{id: String}(Telemetry) -> Ack { + body_limit: 16384, + headers: true, +} +``` + +* `body_limit: ` overrides the service body limit for this endpoint. +* `headers: true` passes the request `axum::http::HeaderMap` to the handler as an + extra argument, immediately after the caller/user and before the path + parameters — the way to read a custom device header or the credential presence + without a separate tower layer. The map is **unredacted**: it still contains the + caller's `Authorization`, `Cookie`, and CSRF headers, so do not log it or + forward it upstream verbatim (use + `ras_auth_core::redact_sensitive_headers_for_auth_transport` if you need to). + +### Versioning + +An endpoint can serve older payload shapes at legacy paths and migrate them to +the canonical types. Give the endpoint a `version:` label and one or more +`versions:` entries, each with its own `path`, `request`, `response`, and a +`migration:` type implementing `ras_rest_core::VersionMigration` for both the +request (legacy → canonical) and response (canonical → legacy): + +```rust,ignore +POST WITH_PERMISSIONS(["admin"]) items/{id: String}(RenameItemV2) -> RenamedItemV2 { + version: "v2", + versions: [ + "v1" { + path: items/{id: String}/rename, + request: RenameItemV1, + response: RenamedItemV1, + migration: RenameMigration, + }, + ], +} +``` + +Each legacy path becomes its own route sharing the endpoint's auth level. + ## Implement The Generated Trait REST handlers return `RestResult`, usually through `RestResponse` helpers: diff --git a/documentation/src/permission-manifests.md b/documentation/src/permission-manifests.md index 9db49e4..39c1ea7 100644 --- a/documentation/src/permission-manifests.md +++ b/documentation/src/permission-manifests.md @@ -12,15 +12,15 @@ Enable manifest generation on the macro crate. The generated API refers to ```toml [dependencies] -ras-rest-macro = { version = "0.2.1", default-features = false, features = ["permissions"] } +ras-rest-macro = { version = "0.3.0", default-features = false, features = ["permissions"] } ras-permission-manifest = "0.1.0" ``` For file services and JSON-RPC services, use the equivalent macro crate: ```toml -ras-file-macro = { version = "0.1.0", default-features = false, features = ["permissions"] } -ras-jsonrpc-macro = { version = "0.2.0", default-features = false, features = ["permissions"] } +ras-file-macro = { version = "0.2.0", default-features = false, features = ["permissions"] } +ras-jsonrpc-macro = { version = "0.3.0", default-features = false, features = ["permissions"] } ``` The `permissions` switch belongs to the macro crate. The macro emits the diff --git a/documentation/src/tutorial/create-the-api-crate.md b/documentation/src/tutorial/create-the-api-crate.md index c617728..68527bc 100644 --- a/documentation/src/tutorial/create-the-api-crate.md +++ b/documentation/src/tutorial/create-the-api-crate.md @@ -14,9 +14,9 @@ name = "workspace-api" edition = "2024" [dependencies] -ras-rest-macro = { version = "0.2.1", default-features = false } -ras-file-macro = { version = "0.1.0", default-features = false } -ras-jsonrpc-bidirectional-macro = { version = "0.1.0", default-features = false } +ras-rest-macro = { version = "0.3.0", default-features = false } +ras-file-macro = { version = "0.2.0", default-features = false } +ras-jsonrpc-bidirectional-macro = { version = "0.2.0", default-features = false } serde = { version = "1.0", features = ["derive"] } schemars = { version = "1.0.0-alpha.20", optional = true } serde_json = { version = "1.0", optional = true } @@ -24,10 +24,10 @@ async-trait = { version = "0.1", optional = true } ras-transport-core = { version = "0.1.0", optional = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -ras-auth-core = { version = "0.1.0", optional = true } -ras-rest-core = { version = "0.1.1", optional = true } -ras-file-core = { version = "0.1.0", optional = true } -ras-jsonrpc-bidirectional-server = { version = "0.1.0", optional = true } +ras-auth-core = { version = "0.2.0", optional = true } +ras-rest-core = { version = "0.2.0", optional = true } +ras-file-core = { version = "0.2.0", optional = true } +ras-jsonrpc-bidirectional-server = { version = "0.2.0", optional = true } axum = { version = "0.8", optional = true } axum-extra = { version = "0.10", optional = true } tokio = { version = "1.0", optional = true } diff --git a/documentation/src/tutorial/implement-the-server.md b/documentation/src/tutorial/implement-the-server.md index 5a653b8..593bfc9 100644 --- a/documentation/src/tutorial/implement-the-server.md +++ b/documentation/src/tutorial/implement-the-server.md @@ -6,9 +6,9 @@ implements the generated traits. ```toml [dependencies] workspace-api = { path = "../workspace-api", default-features = false, features = ["server"] } -ras-auth-core = "0.1.0" -ras-rest-core = "0.1.1" -ras-file-core = "0.1.0" +ras-auth-core = "0.2.0" +ras-rest-core = "0.2.0" +ras-file-core = "0.2.0" axum = "0.8" tokio = { version = "1.0", features = ["full"] } async-trait = "0.1" diff --git a/examples/basic-jsonrpc/api/Cargo.toml b/examples/basic-jsonrpc/api/Cargo.toml index 4023369..ac2ea74 100644 --- a/examples/basic-jsonrpc/api/Cargo.toml +++ b/examples/basic-jsonrpc/api/Cargo.toml @@ -16,10 +16,10 @@ server = ["ras-jsonrpc-macro/server", "dep:axum", "dep:ras-jsonrpc-core"] client = ["ras-jsonrpc-macro/reqwest", "ras-transport-core/reqwest"] [dependencies] -ras-jsonrpc-macro = { path = "../../../crates/rpc/ras-jsonrpc-macro", version = "0.2.0", default-features = false, features = ["permissions"] } -ras-jsonrpc-core = { path = "../../../crates/rpc/ras-jsonrpc-core", version = "0.1.2", optional = true } +ras-jsonrpc-macro = { path = "../../../crates/rpc/ras-jsonrpc-macro", version = "0.3.0", default-features = false, features = ["permissions"] } +ras-jsonrpc-core = { path = "../../../crates/rpc/ras-jsonrpc-core", version = "0.2.0", optional = true } ras-transport-core = { path = "../../../crates/core/ras-transport-core", version = "0.1.0" } -ras-jsonrpc-types = { path = "../../../crates/rpc/ras-jsonrpc-types", version = "0.1.1" } +ras-jsonrpc-types = { path = "../../../crates/rpc/ras-jsonrpc-types", version = "0.2.0" } ras-permission-manifest = { path = "../../../crates/specs/ras-permission-manifest", version = "0.1.0" } serde = { workspace = true } serde_json = { workspace = true } diff --git a/examples/basic-jsonrpc/service/Cargo.toml b/examples/basic-jsonrpc/service/Cargo.toml index dc8d306..ae55498 100644 --- a/examples/basic-jsonrpc/service/Cargo.toml +++ b/examples/basic-jsonrpc/service/Cargo.toml @@ -17,8 +17,8 @@ client = ["basic-jsonrpc-api/client"] [dependencies] basic-jsonrpc-api = { path = "../api", version = "0.1.0", features = ["server"] } -ras-jsonrpc-core = { path = "../../../crates/rpc/ras-jsonrpc-core", version = "0.1.2" } -ras-jsonrpc-types = { path = "../../../crates/rpc/ras-jsonrpc-types", version = "0.1.1" } +ras-jsonrpc-core = { path = "../../../crates/rpc/ras-jsonrpc-core", version = "0.2.0" } +ras-jsonrpc-types = { path = "../../../crates/rpc/ras-jsonrpc-types", version = "0.2.0" } axum = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -30,5 +30,5 @@ uuid = { workspace = true, features = ["v4"] } anyhow = { workspace = true } # Observability -ras-observability-core = { path = "../../../crates/core/ras-observability-core", version = "0.1.0" } -ras-observability-otel = { path = "../../../crates/observability/ras-observability-otel", version = "0.1.0" } +ras-observability-core = { path = "../../../crates/core/ras-observability-core", version = "0.2.0" } +ras-observability-otel = { path = "../../../crates/observability/ras-observability-otel", version = "0.2.0" } diff --git a/examples/bidirectional-chat/api/Cargo.toml b/examples/bidirectional-chat/api/Cargo.toml index 355c9d7..775e9ab 100644 --- a/examples/bidirectional-chat/api/Cargo.toml +++ b/examples/bidirectional-chat/api/Cargo.toml @@ -33,16 +33,16 @@ serde_json = { workspace = true } schemars = { workspace = true } async-trait = { workspace = true } tokio = { workspace = true } -ras-jsonrpc-bidirectional-macro = { path = "../../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro", version = "0.1.0", default-features = false, features = ["permissions"] } -ras-jsonrpc-bidirectional-types = { path = "../../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types", version = "0.1.0" } -ras-jsonrpc-bidirectional-server = { path = "../../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server", version = "0.1.0", optional = true } -ras-jsonrpc-bidirectional-client = { path = "../../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client", version = "0.1.0", optional = true } +ras-jsonrpc-bidirectional-macro = { path = "../../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro", version = "0.2.0", default-features = false, features = ["permissions"] } +ras-jsonrpc-bidirectional-types = { path = "../../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types", version = "0.2.0" } +ras-jsonrpc-bidirectional-server = { path = "../../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server", version = "0.2.0", optional = true } +ras-jsonrpc-bidirectional-client = { path = "../../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client", version = "0.2.0", optional = true } ras-permission-manifest = { path = "../../../crates/specs/ras-permission-manifest", version = "0.1.0" } -ras-auth-core = { path = "../../../crates/core/ras-auth-core", version = "0.1.0" } -ras-rest-core = { path = "../../../crates/rest/ras-rest-core", version = "0.1.1" } +ras-auth-core = { path = "../../../crates/core/ras-auth-core", version = "0.2.0" } +ras-rest-core = { path = "../../../crates/rest/ras-rest-core", version = "0.2.0" } ras-transport-core = { path = "../../../crates/core/ras-transport-core", version = "0.1.0" } -ras-jsonrpc-types = { path = "../../../crates/rpc/ras-jsonrpc-types", version = "0.1.1" } -ras-rest-macro = { path = "../../../crates/rest/ras-rest-macro", version = "0.2.1", default-features = false, features = ["permissions"] } +ras-jsonrpc-types = { path = "../../../crates/rpc/ras-jsonrpc-types", version = "0.2.0" } +ras-rest-macro = { path = "../../../crates/rest/ras-rest-macro", version = "0.3.0", default-features = false, features = ["permissions"] } reqwest = { workspace = true, features = ["json"] } tracing = { workspace = true } axum = { workspace = true, optional = true } diff --git a/examples/bidirectional-chat/server/Cargo.toml b/examples/bidirectional-chat/server/Cargo.toml index 58b7217..6826c71 100644 --- a/examples/bidirectional-chat/server/Cargo.toml +++ b/examples/bidirectional-chat/server/Cargo.toml @@ -13,15 +13,15 @@ readme = "README.md" [dependencies] # Local dependencies bidirectional-chat-api = { path = "../api", version = "0.1.0", features = ["server"] } -ras-auth-core = { path = "../../../crates/core/ras-auth-core", version = "0.1.0" } -ras-jsonrpc-types = { path = "../../../crates/rpc/ras-jsonrpc-types", version = "0.1.1" } -ras-jsonrpc-bidirectional-server = { path = "../../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server", version = "0.1.0" } -ras-jsonrpc-bidirectional-types = { path = "../../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types", version = "0.1.0" } -ras-rest-macro = { path = "../../../crates/rest/ras-rest-macro", version = "0.2.1", default-features = false, features = ["server"] } -ras-rest-core = { path = "../../../crates/rest/ras-rest-core", version = "0.1.1" } +ras-auth-core = { path = "../../../crates/core/ras-auth-core", version = "0.2.0" } +ras-jsonrpc-types = { path = "../../../crates/rpc/ras-jsonrpc-types", version = "0.2.0" } +ras-jsonrpc-bidirectional-server = { path = "../../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server", version = "0.2.0" } +ras-jsonrpc-bidirectional-types = { path = "../../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-types", version = "0.2.0" } +ras-rest-macro = { path = "../../../crates/rest/ras-rest-macro", version = "0.3.0", default-features = false, features = ["server"] } +ras-rest-core = { path = "../../../crates/rest/ras-rest-core", version = "0.2.0" } ras-identity-core = { path = "../../../crates/core/ras-identity-core", version = "0.1.1" } -ras-identity-local = { path = "../../../crates/identity/ras-identity-local", version = "0.2.0" } -ras-identity-session = { path = "../../../crates/identity/ras-identity-session", version = "0.2.0" } +ras-identity-local = { path = "../../../crates/identity/ras-identity-local", version = "0.2.1" } +ras-identity-session = { path = "../../../crates/identity/ras-identity-session", version = "0.3.0" } # Workspace dependencies axum = { workspace = true } diff --git a/examples/bidirectional-chat/server/src/main.rs b/examples/bidirectional-chat/server/src/main.rs index 860895e..0b9be21 100644 --- a/examples/bidirectional-chat/server/src/main.rs +++ b/examples/bidirectional-chat/server/src/main.rs @@ -1555,10 +1555,11 @@ async fn main() -> Result<()> { let session_config = SessionConfig { jwt_secret: config.auth.jwt_secret.clone(), jwt_ttl: chrono::Duration::seconds(config.auth.jwt_ttl_seconds), - refresh_enabled: config.auth.refresh_enabled, enforce_active_sessions: true, algorithm: JwtAlgorithm::from_name(&config.auth.jwt_algorithm) .unwrap_or(JwtAlgorithm::HS256), + iss: None, + aud: None, }; info!( "Creating session service with JWT TTL: {} seconds", @@ -2344,7 +2345,11 @@ mod tests { response_by_id(&messages, "send-before-join").expect("send_message error response"); let error = error_response.error.as_ref().expect("send_message error"); assert_eq!(error.code, ras_jsonrpc_types::error_codes::INTERNAL_ERROR); - assert!(error.message.contains("User not in any room")); + // Handler error detail is no longer forwarded to the client (H3); the + // wire message is generic and the real reason is logged server-side. + // (A production app should return client-facing errors in an Ok response + // rather than via a handler `Err`.) + assert_eq!(error.message, "Internal error"); let join_response = response_by_id(&messages, "join-after-error").expect("join_room response"); @@ -2398,8 +2403,9 @@ mod tests { let second_send = response_by_id(&messages, "send-2").expect("second send response"); let error = second_send.error.as_ref().expect("rate limit error"); assert_eq!(error.code, ras_jsonrpc_types::error_codes::INTERNAL_ERROR); - assert!(error.message.contains("Rate limit exceeded")); - assert!(error.message.contains("1 messages per minute")); + // Handler error detail (the rate-limit reason) is sanitized on the wire + // (H3) and logged server-side instead. + assert_eq!(error.message, "Internal error"); let after_limit = response_by_id(&messages, "list-after-limit").expect("list_rooms after rate limit"); diff --git a/examples/bidirectional-chat/server/tests/auth_lifecycle_tests.rs b/examples/bidirectional-chat/server/tests/auth_lifecycle_tests.rs index e9029c8..b89250c 100644 --- a/examples/bidirectional-chat/server/tests/auth_lifecycle_tests.rs +++ b/examples/bidirectional-chat/server/tests/auth_lifecycle_tests.rs @@ -131,9 +131,10 @@ impl TestChatServer { let session_config = SessionConfig { jwt_secret: config.auth.jwt_secret.clone(), jwt_ttl: chrono::Duration::seconds(config.auth.jwt_ttl_seconds), - refresh_enabled: config.auth.refresh_enabled, enforce_active_sessions: true, algorithm: JwtAlgorithm::HS256, + iss: None, + aud: None, }; let session_service = Arc::new( diff --git a/examples/bidirectional-chat/server/tests/server_tests.rs b/examples/bidirectional-chat/server/tests/server_tests.rs index 942138d..49f4688 100644 --- a/examples/bidirectional-chat/server/tests/server_tests.rs +++ b/examples/bidirectional-chat/server/tests/server_tests.rs @@ -135,9 +135,10 @@ fn config_example_loads_with_session_compatible_secret() -> Result<()> { let session_config = SessionConfig { jwt_secret: config.auth.jwt_secret, jwt_ttl: chrono::Duration::seconds(config.auth.jwt_ttl_seconds), - refresh_enabled: config.auth.refresh_enabled, enforce_active_sessions: true, algorithm: JwtAlgorithm::HS256, + iss: None, + aud: None, }; session_config.validate()?; diff --git a/examples/file-service-example/Cargo.toml b/examples/file-service-example/Cargo.toml index ebe0534..c23e610 100644 --- a/examples/file-service-example/Cargo.toml +++ b/examples/file-service-example/Cargo.toml @@ -21,10 +21,10 @@ axum = { workspace = true } tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -ras-file-macro = { path = "../../crates/rest/ras-file-macro", version = "0.1.0", default-features = false } -ras-file-core = { path = "../../crates/rest/ras-file-core", version = "0.1.0" } +ras-file-macro = { path = "../../crates/rest/ras-file-macro", version = "0.2.0", default-features = false } +ras-file-core = { path = "../../crates/rest/ras-file-core", version = "0.2.0" } ras-transport-core = { path = "../../crates/core/ras-transport-core", version = "0.1.0" } -ras-auth-core = { path = "../../crates/core/ras-auth-core", version = "0.1.0" } +ras-auth-core = { path = "../../crates/core/ras-auth-core", version = "0.2.0" } ras-permission-manifest = { path = "../../crates/specs/ras-permission-manifest", version = "0.1.0" } thiserror = { workspace = true } async-trait = { workspace = true } diff --git a/examples/file-service-wasm/file-service-api/Cargo.toml b/examples/file-service-wasm/file-service-api/Cargo.toml index 36d0387..8d333f2 100644 --- a/examples/file-service-wasm/file-service-api/Cargo.toml +++ b/examples/file-service-wasm/file-service-api/Cargo.toml @@ -14,10 +14,10 @@ readme = "README.md" crate-type = ["rlib"] [dependencies] -ras-file-macro = { path = "../../../crates/rest/ras-file-macro", version = "0.1.0", default-features = false, features = ["permissions"] } -ras-file-core = { path = "../../../crates/rest/ras-file-core", version = "0.1.0", optional = true } +ras-file-macro = { path = "../../../crates/rest/ras-file-macro", version = "0.2.0", default-features = false, features = ["permissions"] } +ras-file-core = { path = "../../../crates/rest/ras-file-core", version = "0.2.0", optional = true } ras-transport-core = { path = "../../../crates/core/ras-transport-core", version = "0.1.0" } -ras-auth-core = { path = "../../../crates/core/ras-auth-core", version = "0.1.0", optional = true } +ras-auth-core = { path = "../../../crates/core/ras-auth-core", version = "0.2.0", optional = true } ras-permission-manifest = { path = "../../../crates/specs/ras-permission-manifest", version = "0.1.0" } serde = { workspace = true, features = ["derive"] } async-trait = { workspace = true, optional = true } diff --git a/examples/file-service-wasm/file-service-backend/Cargo.toml b/examples/file-service-wasm/file-service-backend/Cargo.toml index f25f5ac..bee1882 100644 --- a/examples/file-service-wasm/file-service-backend/Cargo.toml +++ b/examples/file-service-wasm/file-service-backend/Cargo.toml @@ -24,8 +24,8 @@ tokio = { workspace = true, features = ["full"] } tower-http = { workspace = true, features = ["cors", "fs"] } # Authentication -ras-auth-core = { path = "../../../crates/core/ras-auth-core", version = "0.1.0" } -ras-file-core = { path = "../../../crates/rest/ras-file-core", version = "0.1.0" } +ras-auth-core = { path = "../../../crates/core/ras-auth-core", version = "0.2.0" } +ras-file-core = { path = "../../../crates/rest/ras-file-core", version = "0.2.0" } async-trait = { workspace = true } # Error handling diff --git a/examples/oauth2-demo/api/Cargo.toml b/examples/oauth2-demo/api/Cargo.toml index 5528791..009cc70 100644 --- a/examples/oauth2-demo/api/Cargo.toml +++ b/examples/oauth2-demo/api/Cargo.toml @@ -19,10 +19,10 @@ reqwest = ["ras-transport-core/reqwest"] [dependencies] # JSON-RPC infrastructure -ras-jsonrpc-macro = { path = "../../../crates/rpc/ras-jsonrpc-macro", version = "0.2.0", default-features = false, features = ["permissions"] } -ras-jsonrpc-core = { path = "../../../crates/rpc/ras-jsonrpc-core", version = "0.1.2", optional = true } +ras-jsonrpc-macro = { path = "../../../crates/rpc/ras-jsonrpc-macro", version = "0.3.0", default-features = false, features = ["permissions"] } +ras-jsonrpc-core = { path = "../../../crates/rpc/ras-jsonrpc-core", version = "0.2.0", optional = true } ras-transport-core = { path = "../../../crates/core/ras-transport-core", version = "0.1.0" } -ras-jsonrpc-types = { path = "../../../crates/rpc/ras-jsonrpc-types", version = "0.1.1" } +ras-jsonrpc-types = { path = "../../../crates/rpc/ras-jsonrpc-types", version = "0.2.0" } ras-permission-manifest = { path = "../../../crates/specs/ras-permission-manifest", version = "0.1.0" } # Web framework and utilities diff --git a/examples/oauth2-demo/server/Cargo.toml b/examples/oauth2-demo/server/Cargo.toml index 6c4991a..f4d607b 100644 --- a/examples/oauth2-demo/server/Cargo.toml +++ b/examples/oauth2-demo/server/Cargo.toml @@ -17,14 +17,14 @@ client = ["oauth2-demo-api/client"] [dependencies] oauth2-demo-api = { path = "../api", version = "0.1.0", features = ["server"] } # JSON-RPC infrastructure -ras-jsonrpc-macro = { path = "../../../crates/rpc/ras-jsonrpc-macro", version = "0.2.0" } -ras-jsonrpc-core = { path = "../../../crates/rpc/ras-jsonrpc-core", version = "0.1.2" } -ras-jsonrpc-types = { path = "../../../crates/rpc/ras-jsonrpc-types", version = "0.1.1" } +ras-jsonrpc-macro = { path = "../../../crates/rpc/ras-jsonrpc-macro", version = "0.3.0" } +ras-jsonrpc-core = { path = "../../../crates/rpc/ras-jsonrpc-core", version = "0.2.0" } +ras-jsonrpc-types = { path = "../../../crates/rpc/ras-jsonrpc-types", version = "0.2.0" } # Identity management ras-identity-core = { path = "../../../crates/core/ras-identity-core", version = "0.1.1" } -ras-identity-oauth2 = { path = "../../../crates/identity/ras-identity-oauth2", version = "0.1.2" } -ras-identity-session = { path = "../../../crates/identity/ras-identity-session", version = "0.2.0" } +ras-identity-oauth2 = { path = "../../../crates/identity/ras-identity-oauth2", version = "0.2.0" } +ras-identity-session = { path = "../../../crates/identity/ras-identity-session", version = "0.3.0" } # Web framework and utilities axum = { workspace = true } diff --git a/examples/oauth2-demo/server/src/main.rs b/examples/oauth2-demo/server/src/main.rs index da96270..383b4ca 100644 --- a/examples/oauth2-demo/server/src/main.rs +++ b/examples/oauth2-demo/server/src/main.rs @@ -1,9 +1,10 @@ use anyhow::{Context, Result}; -use axum::http::Method; +use axum::http::header::{COOKIE, SET_COOKIE}; +use axum::http::{HeaderMap, HeaderValue, Method}; use axum::{ Json, Router, extract::{Query, State}, - response::{Html, Redirect}, + response::{Html, IntoResponse, Redirect}, routing::{get, post}, }; use ras_identity_oauth2::{ @@ -14,10 +15,35 @@ use ras_identity_session::{JwtAlgorithm, JwtAuthProvider, SessionConfig, Session use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; -use tower_http::cors::{Any, CorsLayer}; +use tower_http::cors::CorsLayer; use tower_http::services::ServeDir; use tracing::{error, info, warn}; +/// Cookie carrying the login-CSRF binding for an in-flight OAuth2 flow. +/// +/// Not marked `Secure` because the example runs over plain HTTP on localhost; a +/// production deployment MUST serve over HTTPS and add `Secure`. +const BINDING_COOKIE: &str = "oauth2_binding"; + +/// Origin the browser front-end is served from (for CORS). +const DEMO_ORIGIN: &str = "http://localhost:3000"; + +fn set_binding_cookie(value: &str) -> String { + format!("{BINDING_COOKIE}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600") +} + +fn clear_binding_cookie() -> String { + format!("{BINDING_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0") +} + +fn read_binding_cookie(headers: &HeaderMap) -> Option { + let cookies = headers.get(COOKIE)?.to_str().ok()?; + cookies.split(';').find_map(|pair| { + let (name, value) = pair.trim().split_once('=')?; + (name == BINDING_COOKIE).then(|| value.to_string()) + }) +} + mod permissions; mod service; @@ -70,11 +96,15 @@ pub struct CallbackQuery { error_description: Option, } -/// OAuth2 flow initiation request +/// OAuth2 flow initiation request. +/// +/// Deliberately carries no client-controlled parameter map: forwarding an +/// arbitrary map into the authorize URL let a caller inject reserved OAuth +/// parameters (H1/H4). Any extra IdP parameters are hardcoded server-side in +/// `create_oauth2_provider`. #[derive(Debug, Serialize, Deserialize)] pub struct StartOAuth2Request { provider_id: String, - additional_params: Option>, } /// OAuth2 flow initiation response @@ -158,9 +188,11 @@ fn create_session_service(config: &AppConfig) -> Result { let session_config = SessionConfig { jwt_secret: config.jwt_secret.clone(), jwt_ttl: chrono::Duration::hours(24), - refresh_enabled: true, - enforce_active_sessions: false, + // Enabled so logout/revocation actually invalidates a session (H4). + enforce_active_sessions: true, algorithm: JwtAlgorithm::HS256, + iss: None, + aud: None, }; let permissions_provider = Arc::new(GoogleOAuth2Permissions::new()); @@ -176,22 +208,42 @@ async fn index_handler() -> Html<&'static str> { Html(include_str!("../static/index.html")) } -/// Handler to start the OAuth2 flow +/// Handler to start the OAuth2 flow. +/// +/// `start_flow` generates a login-CSRF binding; we store it in an HttpOnly +/// cookie that the browser returns on the same-site callback (M2/H4). async fn start_oauth2_handler( State(state): State, Json(request): Json, -) -> Result, String> { +) -> Result { info!("Starting OAuth2 flow for provider: {}", request.provider_id); match state .oauth2_provider - .start_flow(&request.provider_id, request.additional_params) + .start_flow(&request.provider_id, None) .await { - Ok(OAuth2Response::AuthorizationUrl { url, state }) => Ok(Json(StartOAuth2Response { - authorization_url: url, + Ok(OAuth2Response::AuthorizationUrl { + url, state, - })), + binding, + }) => { + let mut headers = HeaderMap::new(); + if let Some(binding) = binding { + headers.insert( + SET_COOKIE, + HeaderValue::from_str(&set_binding_cookie(&binding)) + .map_err(|e| format!("invalid cookie: {e}"))?, + ); + } + Ok(( + headers, + Json(StartOAuth2Response { + authorization_url: url, + state, + }), + )) + } Ok(OAuth2Response::Error { message }) => Err(format!("OAuth2 error: {}", message)), Err(e) => Err(format!("OAuth2 provider error: {}", e)), } @@ -200,10 +252,18 @@ async fn start_oauth2_handler( /// Handler for OAuth2 callback async fn oauth2_callback_handler( State(state): State, + headers: HeaderMap, Query(callback_query): Query, -) -> Result { +) -> Result { info!("Handling OAuth2 callback"); + // The binding cookie is always cleared once the flow completes (or fails). + let mut response_headers = HeaderMap::new(); + response_headers.insert( + SET_COOKIE, + HeaderValue::from_str(&clear_binding_cookie()).map_err(|e| format!("invalid cookie: {e}"))?, + ); + // Check for error in callback if let Some(error) = &callback_query.error { let error_desc = callback_query @@ -211,7 +271,7 @@ async fn oauth2_callback_handler( .as_deref() .unwrap_or("No description"); error!("OAuth2 callback error: {}: {}", error, error_desc); - return Ok(Redirect::to("/error")); + return Ok((response_headers, Redirect::to("/error"))); } let code = callback_query @@ -222,6 +282,9 @@ async fn oauth2_callback_handler( .state .ok_or_else(|| "Missing state parameter in callback".to_string())?; + // Echo the login-CSRF binding read back from the cookie (M2/H4). + let binding = read_binding_cookie(&headers); + // Complete the OAuth2 flow let auth_payload = OAuth2AuthPayload::Callback { provider_id: "google".to_string(), @@ -229,7 +292,7 @@ async fn oauth2_callback_handler( state: state_param, error: callback_query.error, error_description: callback_query.error_description, - binding: None, + binding, }; let payload_json = serde_json::to_value(auth_payload) @@ -242,12 +305,17 @@ async fn oauth2_callback_handler( .await .map_err(|e| format!("Failed to create session: {}", e))?; - info!("OAuth2 callback successful, redirecting with token"); - - // The success page immediately moves the token into sessionStorage and - // clears it from the URL. A production app should use its own token - // delivery policy. - Ok(Redirect::to(&format!("/success?token={}", token))) + info!("OAuth2 callback successful, redirecting to success page"); + + // Deliver the JWT in the URL *fragment*, not the query string: fragments are + // never sent to the server (no access-log entry) and are not included in the + // `Referer` header. success.html moves it into sessionStorage and clears the + // fragment immediately. A production app should prefer a Set-Cookie session + // (which the library now pairs with CSRF) over any URL-based delivery. + Ok(( + response_headers, + Redirect::to(&format!("/success#token={}", token)), + )) } /// Handler for success page. @@ -332,10 +400,11 @@ async fn main() -> Result<()> { .route("/api-docs", get(api_docs_handler)) .nest_service("/static", ServeDir::new("../static")) .layer( + // Restrict CORS to the demo's own origin rather than `Any` (H4). CorsLayer::new() - .allow_origin(Any) + .allow_origin(HeaderValue::from_static(DEMO_ORIGIN)) .allow_methods([Method::GET, Method::POST]) - .allow_headers(Any), + .allow_headers([axum::http::header::CONTENT_TYPE]), ) .with_state(app_state); @@ -390,4 +459,40 @@ mod static_page_tests { assert!(SUCCESS_HTML.contains("sessionStorage.setItem('jwt_token', jwtToken)")); assert!(SUCCESS_HTML.contains("onclick=\"storeAndRedirect()\">Interactive API Docs")); } + + #[test] + fn success_page_reads_token_from_fragment_and_clears_url() { + // Token is read from the URL fragment (never sent to the server) and the + // URL is scrubbed immediately (H4). + assert!(SUCCESS_HTML.contains("window.location.hash")); + assert!(SUCCESS_HTML.contains("history.replaceState")); + // It must NOT read the token from the query string anymore. + assert!( + !SUCCESS_HTML.contains("URLSearchParams(window.location.search)"), + "success page must not read the token from the query string" + ); + } +} + +#[cfg(test)] +mod handler_tests { + use super::*; + + #[test] + fn binding_cookie_round_trips_through_headers() { + let set = set_binding_cookie("abc-123"); + assert!(set.contains("oauth2_binding=abc-123")); + assert!(set.contains("HttpOnly")); + assert!(set.contains("SameSite=Lax")); + + let mut headers = HeaderMap::new(); + headers.insert( + COOKIE, + HeaderValue::from_static("theme=dark; oauth2_binding=abc-123"), + ); + assert_eq!(read_binding_cookie(&headers).as_deref(), Some("abc-123")); + + // Cleared cookie has Max-Age=0. + assert!(clear_binding_cookie().contains("Max-Age=0")); + } } diff --git a/examples/oauth2-demo/server/src/permissions.rs b/examples/oauth2-demo/server/src/permissions.rs index dcf2416..eafafcc 100644 --- a/examples/oauth2-demo/server/src/permissions.rs +++ b/examples/oauth2-demo/server/src/permissions.rs @@ -29,9 +29,24 @@ impl GoogleOAuth2Permissions { permissions.push("user:read".to_string()); permissions.push("profile:read".to_string()); + // Only an IdP-verified email may drive privilege decisions. An + // unverified email address is attacker-controllable (the IdP never + // confirmed the user owns it), so it must never grant admin (H4/M6). + let email_verified = identity + .metadata + .as_ref() + .and_then(|metadata| metadata.get("email_verified")) + .and_then(|value| value.as_bool()) + .unwrap_or(false); + // Check email domain for additional permissions if let Some(email) = &identity.email { - if email.ends_with("@example.com") { + if !email_verified { + info!( + "Skipping domain-based permissions: email not verified: {}", + email + ); + } else if email.ends_with("@example.com") { // Users from example.com get admin permissions permissions.push("admin:read".to_string()); permissions.push("admin:write".to_string()); @@ -220,4 +235,30 @@ mod tests { assert!(permissions.contains(&"user:read".to_string())); assert!(!permissions.contains(&"email:verified".to_string())); } + + #[tokio::test] + async fn unverified_admin_email_is_not_granted_admin() { + // The security fix (H4/M6): an UNVERIFIED @example.com address must not + // receive admin, since the IdP never confirmed the user owns it. + let provider = GoogleOAuth2Permissions::new(); + let identity = create_test_identity("42", Some("attacker@example.com"), Some(false)); + + let permissions = provider.get_permissions(&identity).await.unwrap(); + + assert!(permissions.contains(&"user:read".to_string())); + assert!(!permissions.contains(&"admin:read".to_string())); + assert!(!permissions.contains(&"admin:write".to_string())); + assert!(!permissions.contains(&"system:manage".to_string())); + } + + #[tokio::test] + async fn missing_email_verified_claim_is_not_granted_admin() { + // No email_verified claim at all -> treated as unverified. + let provider = GoogleOAuth2Permissions::new(); + let identity = create_test_identity("43", Some("someone@example.com"), None); + + let permissions = provider.get_permissions(&identity).await.unwrap(); + + assert!(!permissions.contains(&"admin:read".to_string())); + } } diff --git a/examples/oauth2-demo/server/static/index.html b/examples/oauth2-demo/server/static/index.html index 07df730..535c34f 100644 --- a/examples/oauth2-demo/server/static/index.html +++ b/examples/oauth2-demo/server/static/index.html @@ -635,8 +635,7 @@

Authentication Flow

'Content-Type': 'application/json', }, body: JSON.stringify({ - provider_id: 'google', - additional_params: null + provider_id: 'google' }) }); diff --git a/examples/oauth2-demo/server/static/success.html b/examples/oauth2-demo/server/static/success.html index 4d2d7c1..c46ecd7 100644 --- a/examples/oauth2-demo/server/static/success.html +++ b/examples/oauth2-demo/server/static/success.html @@ -439,16 +439,27 @@

Permission System

let jwtToken = ''; let userInfo = null; - // Get token from URL parameter + // The token is delivered in the URL *fragment* (`#token=...`), which the + // browser never sends to the server and never puts in the Referer header. + // Move it into sessionStorage and strip it from the address bar / history + // immediately so it does not linger in the URL. document.addEventListener('DOMContentLoaded', function() { - const urlParams = new URLSearchParams(window.location.search); - jwtToken = urlParams.get('token') || ''; - + const hash = window.location.hash.startsWith('#') + ? window.location.hash.slice(1) + : ''; + const hashParams = new URLSearchParams(hash); + jwtToken = hashParams.get('token') + || sessionStorage.getItem('jwt_token') + || ''; + if (jwtToken) { + sessionStorage.setItem('jwt_token', jwtToken); + // Remove the token from the URL bar and browser history. + history.replaceState(null, '', window.location.pathname); displayToken(); parseTokenInfo(); } else { - document.getElementById('tokenContent').textContent = 'No token found in URL'; + document.getElementById('tokenContent').textContent = 'No token found'; } }); diff --git a/examples/rest-wasm-example/rest-api/Cargo.toml b/examples/rest-wasm-example/rest-api/Cargo.toml index 61ac26d..c506e7c 100644 --- a/examples/rest-wasm-example/rest-api/Cargo.toml +++ b/examples/rest-wasm-example/rest-api/Cargo.toml @@ -14,10 +14,10 @@ readme = "README.md" crate-type = ["rlib"] [dependencies] -ras-rest-macro = { path = "../../../crates/rest/ras-rest-macro", version = "0.2.1", default-features = false, features = ["permissions"] } -ras-auth-core = { path = "../../../crates/core/ras-auth-core", version = "0.1.0", optional = true } +ras-rest-macro = { path = "../../../crates/rest/ras-rest-macro", version = "0.3.0", default-features = false, features = ["permissions"] } +ras-auth-core = { path = "../../../crates/core/ras-auth-core", version = "0.2.0", optional = true } ras-permission-manifest = { path = "../../../crates/specs/ras-permission-manifest", version = "0.1.0" } -ras-rest-core = { path = "../../../crates/rest/ras-rest-core", version = "0.1.1", optional = true } +ras-rest-core = { path = "../../../crates/rest/ras-rest-core", version = "0.2.0", optional = true } ras-transport-core = { path = "../../../crates/core/ras-transport-core", version = "0.1.0" } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/examples/rest-wasm-example/rest-backend/Cargo.toml b/examples/rest-wasm-example/rest-backend/Cargo.toml index 0903298..6df262e 100644 --- a/examples/rest-wasm-example/rest-backend/Cargo.toml +++ b/examples/rest-wasm-example/rest-backend/Cargo.toml @@ -20,8 +20,8 @@ ras-permission-manifest = { path = "../../../crates/specs/ras-permission-manifes [dependencies] rest-api = { path = "../rest-api", version = "0.1.0", features = ["server"] } -ras-auth-core = { path = "../../../crates/core/ras-auth-core", version = "0.1.0" } -ras-rest-core = { path = "../../../crates/rest/ras-rest-core", version = "0.1.1" } +ras-auth-core = { path = "../../../crates/core/ras-auth-core", version = "0.2.0" } +ras-rest-core = { path = "../../../crates/rest/ras-rest-core", version = "0.2.0" } axum = { workspace = true } axum-extra = { workspace = true } tokio = { workspace = true, features = ["full"] } diff --git a/tests/playwright/fixtures/jsonrpc-fixture/Cargo.toml b/tests/playwright/fixtures/jsonrpc-fixture/Cargo.toml index ac675d3..d2ca980 100644 --- a/tests/playwright/fixtures/jsonrpc-fixture/Cargo.toml +++ b/tests/playwright/fixtures/jsonrpc-fixture/Cargo.toml @@ -18,11 +18,11 @@ client = ["ras-jsonrpc-macro/reqwest", "ras-transport-core/reqwest"] [dependencies] anyhow = { workspace = true } axum = { workspace = true } -ras-auth-core = { path = "../../../../crates/core/ras-auth-core", version = "0.1.0" } +ras-auth-core = { path = "../../../../crates/core/ras-auth-core", version = "0.2.0" } ras-transport-core = { path = "../../../../crates/core/ras-transport-core", version = "0.1.0" } -ras-jsonrpc-core = { path = "../../../../crates/rpc/ras-jsonrpc-core", version = "0.1.2" } -ras-jsonrpc-macro = { path = "../../../../crates/rpc/ras-jsonrpc-macro", version = "0.2.0", default-features = false } -ras-jsonrpc-types = { path = "../../../../crates/rpc/ras-jsonrpc-types", version = "0.1.1" } +ras-jsonrpc-core = { path = "../../../../crates/rpc/ras-jsonrpc-core", version = "0.2.0" } +ras-jsonrpc-macro = { path = "../../../../crates/rpc/ras-jsonrpc-macro", version = "0.3.0", default-features = false } +ras-jsonrpc-types = { path = "../../../../crates/rpc/ras-jsonrpc-types", version = "0.2.0" } ras-permission-manifest = { path = "../../../../crates/specs/ras-permission-manifest", version = "0.1.0" } reqwest = { workspace = true } schemars = { workspace = true } diff --git a/tests/playwright/fixtures/rest-fixture/Cargo.toml b/tests/playwright/fixtures/rest-fixture/Cargo.toml index daf7a2e..178b6f6 100644 --- a/tests/playwright/fixtures/rest-fixture/Cargo.toml +++ b/tests/playwright/fixtures/rest-fixture/Cargo.toml @@ -20,10 +20,10 @@ anyhow = { workspace = true } async-trait = { workspace = true } axum = { workspace = true } axum-extra = { workspace = true } -ras-auth-core = { path = "../../../../crates/core/ras-auth-core", version = "0.1.0" } +ras-auth-core = { path = "../../../../crates/core/ras-auth-core", version = "0.2.0" } ras-transport-core = { path = "../../../../crates/core/ras-transport-core", version = "0.1.0" } -ras-rest-core = { path = "../../../../crates/rest/ras-rest-core", version = "0.1.1" } -ras-rest-macro = { path = "../../../../crates/rest/ras-rest-macro", version = "0.2.1", default-features = false } +ras-rest-core = { path = "../../../../crates/rest/ras-rest-core", version = "0.2.0" } +ras-rest-macro = { path = "../../../../crates/rest/ras-rest-macro", version = "0.3.0", default-features = false } ras-permission-manifest = { path = "../../../../crates/specs/ras-permission-manifest", version = "0.1.0" } reqwest = { workspace = true } schemars = { workspace = true }