diff --git a/CHANGELOG.md b/CHANGELOG.md index 29a752f..9614ad6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added - 2026-06-10 +- Service-coms and gateway extension (issues #12–#15), shipped as new 0.1.0 crate families: + - `ras-authorization-token`: shared RAS token claims (`ras_web_session`, `ras_internal_access`, `ras_gateway_access` families), ES256/EdDSA/HS256 signing, `kid`-based key rotation with JWKS publication, and a strict validator (asymmetric-only algorithm allowlist by default, issuer/audience/token-type pinning, key-type-confusion guard, clock-skew-aware expiry). + - `ras-integration-core` (#12): outbound token framework — pluggable `TokenSource`s with token families, bounds-checked caching `TokenManager` (family/subject/audience/scopes/config-version cache keys, early refresh, concurrent-refresh dedup), `GrantStore` trait, `SecretString` redaction, and capability-scoped `AuthorizedHttpClient`s over `ras-transport-core` with exact-host outbound validation and no automatic replay. + - `ras-integration-oauth2`: OAuth2/OIDC token source (refresh-token flow with grant scope subset checks and transactional rotation persistence, client credentials with audience forwarding, typed `ConsentRequired` errors) plus a PKCE S256 `ConsentFlow` with single-use, expiring, fully-bound state. + - `ras-authorization-core` (#13, embedded mode): RAS-native authorization control plane — service registry, audience-scoped grants and roles, permission-manifest import with unknown-permission rejection, pluggable `ServiceIdentityVerifier` (constant-time static-secret dev verifier included), fail-closed internal token issuer with topology policy enforcement, JWKS/key rotation, append-only audit events, embedded axum authority routes, and `RasTokenAuthProvider` for downstream validation through existing generated services. + - `ras-integration-ras`: `RasInternalTokenSource` bridging the two — obtains internal service tokens from the authority (in-process `EmbeddedAuthority` or HTTP `HttpAuthority`), never minting locally. + - `ras-authorization-gateway` (#14): optional token-narrowing reverse proxy — local web-session validation via JWKS, deterministic longest-prefix routing, single-audience derived tokens that never outlive the session, header hygiene, streaming bodies, generated-profile consumption, and fail-closed WebSocket upgrades (v1). + - `ras-topology-core` + `ras-topology-macro` (#15): `ras_topology!` compile-checked service graphs with build-time validation (audience uniqueness, route conflicts, exposure rules, manifest-checked edge permissions) emitting deterministic authorization-policy, gateway-profile, and Mermaid artifacts consumed by the authority and gateway. + - `examples/authorization-demo`: end-to-end demo wiring topology, embedded authority, internal service calls through generated clients, and the gateway in front of two generated REST services, with a full in-process integration test suite. +- New mdBook chapters: Service-To-Service Auth, Outbound Integrations, The Auth Gateway, and Topology. + ### Changed - 2026-06-06 - REST, JSON-RPC, and file generated-client APIs are now consistent: builders take the URL at construction, auth state is cloned, `build_with_transport(...)` is always available for generated clients, public timeout variants take `Duration`, and default reqwest-backed `build()` is emitted only when the macro crate's `reqwest` feature is enabled. - Macro client features now distinguish transport-injected clients from default reqwest clients: `client` emits generated clients using `ras-transport-core`, while `reqwest` enables the default `ReqwestTransport` constructor. diff --git a/Cargo.lock b/Cargo.lock index f97130b..b7a8e0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,6 +91,37 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "authorization-demo" +version = "0.1.0" +dependencies = [ + "async-trait", + "axum", + "axum-extra", + "axum-test", + "bytes", + "chrono", + "futures", + "ras-auth-core", + "ras-authorization-core", + "ras-authorization-gateway", + "ras-authorization-token", + "ras-integration-core", + "ras-integration-ras", + "ras-permission-manifest", + "ras-rest-core", + "ras-rest-macro", + "ras-topology-core", + "ras-topology-macro", + "ras-transport-core", + "schemars", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -207,6 +238,12 @@ dependencies = [ "url", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.21.7" @@ -564,6 +601,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-random" version = "0.1.18" @@ -733,6 +776,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -766,6 +821,33 @@ dependencies = [ "syn", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "darling" version = "0.23.0" @@ -820,6 +902,17 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -842,6 +935,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] @@ -985,12 +1079,71 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "email_address" version = "0.2.9" @@ -1071,6 +1224,22 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "file-service-api" version = "0.1.0" @@ -1280,6 +1449,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1344,6 +1514,17 @@ dependencies = [ "web-sys", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.4.13" @@ -2173,6 +2354,18 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -2219,6 +2412,15 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2338,6 +2540,16 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "playwright-jsonrpc-fixture" version = "0.0.0" @@ -2458,6 +2670,15 @@ dependencies = [ "syn", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2649,6 +2870,64 @@ dependencies = [ "tokio", ] +[[package]] +name = "ras-authorization-core" +version = "0.1.0" +dependencies = [ + "async-trait", + "axum", + "axum-test", + "chrono", + "ras-auth-core", + "ras-authorization-token", + "ras-permission-manifest", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "ras-authorization-gateway" +version = "0.1.0" +dependencies = [ + "async-trait", + "axum", + "axum-test", + "bytes", + "chrono", + "cookie", + "futures", + "futures-util", + "ras-authorization-token", + "ras-transport-core", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "toml", + "tracing", +] + +[[package]] +name = "ras-authorization-token" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "chrono", + "ed25519-dalek", + "hmac", + "p256", + "rand_core 0.6.4", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.18", + "tokio", + "uuid", +] + [[package]] name = "ras-file-core" version = "0.1.0" @@ -2753,6 +3032,62 @@ dependencies = [ "uuid", ] +[[package]] +name = "ras-integration-core" +version = "0.1.0" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures", + "http", + "ras-transport-core", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "url", +] + +[[package]] +name = "ras-integration-oauth2" +version = "0.1.0" +dependencies = [ + "async-trait", + "base64 0.22.1", + "bytes", + "chrono", + "futures", + "http", + "rand 0.8.6", + "ras-integration-core", + "ras-transport-core", + "serde", + "serde_json", + "serde_urlencoded", + "sha2", + "tokio", + "url", +] + +[[package]] +name = "ras-integration-ras" +version = "0.1.0" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures", + "ras-authorization-core", + "ras-authorization-token", + "ras-integration-core", + "ras-permission-manifest", + "ras-transport-core", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "ras-jsonrpc-bidirectional-client" version = "0.1.0" @@ -2987,6 +3322,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "ras-topology-core" +version = "0.1.0" +dependencies = [ + "ras-authorization-core", + "ras-authorization-gateway", + "ras-permission-manifest", + "ras-topology-macro", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.18", + "toml", +] + +[[package]] +name = "ras-topology-macro" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ras-transport-core" version = "0.1.0" @@ -3209,6 +3568,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -3266,6 +3635,15 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.44" @@ -3379,6 +3757,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "semver" version = "1.0.28" @@ -3563,6 +3955,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "siphasher" version = "1.0.2" @@ -3597,6 +3999,16 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index 8180207..806b505 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,9 @@ [workspace] members = [ + "crates/authorization/*", "crates/core/*", "crates/identity/*", + "crates/integration/*", "crates/observability/*", "crates/rest/*", "crates/rpc/bidirectional/*", @@ -9,6 +11,8 @@ members = [ "crates/rpc/ras-jsonrpc-macro", "crates/rpc/ras-jsonrpc-types", "crates/specs/*", + "crates/topology/*", + "examples/authorization-demo", "examples/basic-jsonrpc/*", "examples/bidirectional-chat/api", "examples/bidirectional-chat/server", @@ -40,6 +44,7 @@ dominator = "0.5" dotenvy = "0.15" dwind = "0.3.2" dwind-macros = "0.2.2" +ed25519-dalek = { version = "2.1", features = ["rand_core", "pkcs8", "pem"] } futures = "0.3" futures-core = "0.3" futures-util = "0.3" @@ -50,6 +55,7 @@ js-sys = "0.3" mime_guess = "2.0" once_cell = "1.20" opentelemetry = "0.32" +p256 = { version = "0.13", features = ["pkcs8"] } opentelemetry-prometheus = "0.32" proc-macro2 = "1.0" prometheus = "0.14" @@ -62,6 +68,7 @@ serde_urlencoded = "0.7" sha2 = "0.10" tempfile = "3.13" thiserror = "2.0" +toml = "0.8" tokio-tungstenite = "0.26" tower-http = "0.6" tracing = "0.1" diff --git a/README.md b/README.md index 3417029..94f9c9e 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Published documentation: The Rust Agent Stack provides reusable building blocks for distributed agent systems: - **Pluggable Authentication** - JWT sessions, OAuth2, local username/password auth, secure cookie transport, and reusable authorization traits +- **Multi-Service Authorization** - RAS-native authorization control plane with audience-scoped grants, internal service-to-service tokens (JWKS-validated), an outbound token framework for third-party and internal APIs, an optional token-narrowing auth gateway, and compile-checked service topologies with generated policy artifacts - **Type-Safe APIs** - Procedural macros for JSON-RPC, REST, and file services - **WebSocket Support** - Bidirectional real-time communication - **File Services** - Type-safe file upload/download with streaming support diff --git a/crates/authorization/ras-authorization-core/Cargo.toml b/crates/authorization/ras-authorization-core/Cargo.toml new file mode 100644 index 0000000..5c68db4 --- /dev/null +++ b/crates/authorization/ras-authorization-core/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "ras-authorization-core" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +description = "RAS-native authorization control plane: service registry, audience-scoped grants, pluggable service identity, and the internal service token issuer" +keywords = ["api", "auth", "authorization", "rbac", "tokens"] +categories = ["authentication", "web-programming"] +license = "MIT OR Apache-2.0" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" +readme = "README.md" + +[dependencies] +ras-authorization-token = { path = "../ras-authorization-token", version = "0.1.0" } +ras-auth-core = { path = "../../core/ras-auth-core", version = "0.1.0" } +ras-permission-manifest = { path = "../../specs/ras-permission-manifest", version = "0.1.0" } + +async-trait = { workspace = true } +axum = { workspace = true } +chrono = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +axum-test = { workspace = true } diff --git a/crates/authorization/ras-authorization-core/README.md b/crates/authorization/ras-authorization-core/README.md new file mode 100644 index 0000000..cba113a --- /dev/null +++ b/crates/authorization/ras-authorization-core/README.md @@ -0,0 +1,37 @@ +# ras-authorization-core + +RAS-native authorization control plane (issue #13, embedded mode): external +identity providers authenticate humans, RAS owns application authorization. + +- **Audience-scoped grants**: "principal X may use permission P at audience + A" — identical permission strings on different services never satisfy + each other. Roles bundle audience-scoped permissions; principals are + users, services, service accounts, or applications. +- **Manifest-driven vocabulary**: import generated permission manifests per + audience; grants of unknown permissions are rejected unless made through + the explicit `grant_custom` path. +- **Pluggable service identity**: `ServiceIdentityVerifier` trait with a + constant-time static-secret verifier for dev/simple deployments; + production should adapt workload identity (Kubernetes SA JWTs, + SPIFFE/SPIRE, mTLS) behind the same trait. +- **Fail-closed token issuance**: identity → registration → audience + existence → audience-scoped grants → (optional) topology + `ServiceGraphPolicy` edges with permission ceilings, minting short-lived + single-audience `ras_internal_access` JWTs stamped with `authz_version`. +- **JWKS + rotation**: downstream services validate offline; emergency + removal of retired keys immediately invalidates their tokens. +- **Append-only audit**: registrations, grants, issuance outcomes, and key + changes — never containing secrets or token values. +- **Embedded authority routes**: `authority_router` mounts + `POST /auth/token` and `GET /auth/jwks.json` into any axum app (the + default deployment preset); central-authority deployments serve the same + router standalone. +- **Downstream validation**: `RasTokenAuthProvider` implements + `ras-auth-core`'s `AuthProvider`, so existing generated services accept + RAS internal and gateway tokens with their `WITH_PERMISSIONS` + enforcement unchanged. + +Storage is trait-based (`AuthorizationStore`); the in-memory implementation +serves embedded mode, tests, and examples. See the +`examples/authorization-demo` crate and the Service-To-Service Auth book +chapter for full wiring. diff --git a/crates/authorization/ras-authorization-core/src/audit.rs b/crates/authorization/ras-authorization-core/src/audit.rs new file mode 100644 index 0000000..34c65f9 --- /dev/null +++ b/crates/authorization/ras-authorization-core/src/audit.rs @@ -0,0 +1,102 @@ +//! Audit events for authorization decisions. +//! +//! Events carry ids, audiences, permissions, and outcomes — never secrets, +//! proofs, or token values. The sink API is append-only. + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use tokio::sync::Mutex; + +/// What happened. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuditEventKind { + ServiceRegistered, + ServiceDisabled, + ManifestImported, + RoleDefined, + RoleBound, + GrantAdded, + GrantRevoked, + TokenIssued, + TokenIssuanceDenied, + IdentityVerificationFailed, + SigningKeyRotated, + SigningKeyRemoved, + PolicyLoaded, +} + +/// One append-only audit record. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuditEvent { + pub timestamp: DateTime, + pub kind: AuditEventKind, + /// The acting principal/service id, where applicable. + pub actor: Option, + /// The target (audience, service id, role id, key id), where applicable. + pub target: Option, + /// Human-readable detail. Must never contain secret or token material. + pub detail: String, +} + +impl AuditEvent { + pub fn new(kind: AuditEventKind, detail: impl Into) -> Self { + Self { + timestamp: Utc::now(), + kind, + actor: None, + target: None, + detail: detail.into(), + } + } + + pub fn with_actor(mut self, actor: impl Into) -> Self { + self.actor = Some(actor.into()); + self + } + + pub fn with_target(mut self, target: impl Into) -> Self { + self.target = Some(target.into()); + self + } +} + +/// Receives audit events. Implementations must treat the stream as +/// append-only; there is deliberately no removal API. +#[async_trait] +pub trait AuditSink: Send + Sync { + async fn record(&self, event: AuditEvent); +} + +/// In-memory append-only audit sink for embedded mode, tests, and examples. +#[derive(Default)] +pub struct InMemoryAuditSink { + events: Mutex>, +} + +impl InMemoryAuditSink { + pub fn new() -> Self { + Self::default() + } + + /// Snapshot of all recorded events, oldest first. + pub async fn events(&self) -> Vec { + self.events.lock().await.clone() + } +} + +#[async_trait] +impl AuditSink for InMemoryAuditSink { + async fn record(&self, event: AuditEvent) { + self.events.lock().await.push(event); + } +} + +/// A sink that drops everything (for callers that do not want auditing). +pub struct NoopAuditSink; + +#[async_trait] +impl AuditSink for NoopAuditSink { + async fn record(&self, _event: AuditEvent) {} +} diff --git a/crates/authorization/ras-authorization-core/src/auth_provider.rs b/crates/authorization/ras-authorization-core/src/auth_provider.rs new file mode 100644 index 0000000..dbfe63f --- /dev/null +++ b/crates/authorization/ras-authorization-core/src/auth_provider.rs @@ -0,0 +1,149 @@ +//! Downstream validation: accept RAS-issued tokens in existing RAS services. +//! +//! [`RasTokenAuthProvider`] implements `ras-auth-core`'s [`AuthProvider`], +//! so generated REST/JSON-RPC/file services accept internal service tokens +//! (and gateway-derived tokens) with their existing permission enforcement — +//! no macro changes required. + +use ras_auth_core::{AuthError, AuthFuture, AuthProvider, AuthenticatedUser}; +use ras_authorization_token::{KeyResolver, RasClaims, TokenError, TokenValidator}; + +/// An [`AuthProvider`] validating RAS tokens (internal service tokens, +/// gateway-derived tokens) against a [`TokenValidator`]. +/// +/// The validator's options pin the expected issuer, audience, token types, +/// and algorithm allowlist; the resolver is typically the authority's JWKS. +/// Validated claims map to an [`AuthenticatedUser`] whose permissions are +/// the token's single-audience permission set, so generated services enforce +/// their `WITH_PERMISSIONS` requirements unchanged. +pub struct RasTokenAuthProvider { + validator: TokenValidator, +} + +impl RasTokenAuthProvider { + pub fn new(validator: TokenValidator) -> Self { + Self { validator } + } + + fn to_user(claims: RasClaims) -> AuthenticatedUser { + AuthenticatedUser { + user_id: claims.sub.clone(), + permissions: claims.permissions.iter().cloned().collect(), + metadata: claims.metadata, + } + } +} + +impl AuthProvider for RasTokenAuthProvider { + fn authenticate(&self, token: String) -> AuthFuture<'_> { + Box::pin(async move { + let claims = self.validator.validate(&token).map_err(|err| match err { + TokenError::Expired => AuthError::TokenExpired, + _ => AuthError::InvalidToken, + })?; + Ok(Self::to_user(claims)) + }) + } +} + +#[cfg(test)] +mod tests { + use chrono::Duration; + use ras_authorization_token::{ + AudiencePolicy, KeyRing, PrincipalKind, SigningKey, TokenType, ValidationOptions, + }; + + use super::*; + + #[tokio::test] + async fn internal_token_maps_to_authenticated_user_with_permissions() { + let ring = KeyRing::new(SigningKey::generate_es256("k1")); + let claims = RasClaims::internal_service( + "https://auth.internal", + "billing-service", + PrincipalKind::Service, + "invoice-service", + vec!["invoice:read".to_string()], + Duration::minutes(5), + ); + let token = ring.sign(&claims).unwrap(); + + let provider = RasTokenAuthProvider::new(TokenValidator::new( + ring.jwks(), + ValidationOptions::new( + "https://auth.internal", + AudiencePolicy::Exact("invoice-service".to_string()), + vec![TokenType::InternalService], + ), + )); + + let user = provider.authenticate(token).await.unwrap(); + assert_eq!(user.user_id, "billing-service"); + assert!(user.permissions.contains("invoice:read")); + } + + #[tokio::test] + async fn wrong_audience_and_garbage_tokens_are_invalid() { + let ring = KeyRing::new(SigningKey::generate_es256("k1")); + let claims = RasClaims::internal_service( + "https://auth.internal", + "billing-service", + PrincipalKind::Service, + "other-service", + vec![], + Duration::minutes(5), + ); + let token = ring.sign(&claims).unwrap(); + + let provider = RasTokenAuthProvider::new(TokenValidator::new( + ring.jwks(), + ValidationOptions::new( + "https://auth.internal", + AudiencePolicy::Exact("invoice-service".to_string()), + vec![TokenType::InternalService], + ), + )); + + assert!(matches!( + provider.authenticate(token).await.unwrap_err(), + AuthError::InvalidToken + )); + assert!(matches!( + provider + .authenticate("garbage".to_string()) + .await + .unwrap_err(), + AuthError::InvalidToken + )); + } + + #[tokio::test] + async fn expired_token_maps_to_token_expired() { + let ring = KeyRing::new(SigningKey::generate_es256("k1")); + let mut claims = RasClaims::internal_service( + "https://auth.internal", + "billing-service", + PrincipalKind::Service, + "invoice-service", + vec![], + Duration::minutes(5), + ); + claims.iat -= 7200; + claims.exp = claims.iat + 60; + let token = ring.sign(&claims).unwrap(); + + let provider = RasTokenAuthProvider::new(TokenValidator::new( + ring.jwks(), + ValidationOptions::new( + "https://auth.internal", + AudiencePolicy::Exact("invoice-service".to_string()), + vec![TokenType::InternalService], + ), + )); + + assert!(matches!( + provider.authenticate(token).await.unwrap_err(), + AuthError::TokenExpired + )); + } +} diff --git a/crates/authorization/ras-authorization-core/src/error.rs b/crates/authorization/ras-authorization-core/src/error.rs new file mode 100644 index 0000000..3d2922f --- /dev/null +++ b/crates/authorization/ras-authorization-core/src/error.rs @@ -0,0 +1,58 @@ +//! Authorization control-plane errors. + +use thiserror::Error; + +/// Errors from the authorization store, identity verification, and token +/// issuance. All issuance paths fail closed; variants never carry secret or +/// token material. +#[derive(Debug, Error)] +pub enum AuthzError { + /// Service identity proof did not verify. Deliberately carries no + /// detail about why. + #[error("service identity verification failed for {service_id:?}")] + IdentityVerificationFailed { service_id: String }, + + /// No service is registered under this id. + #[error("unknown service {service_id:?}")] + UnknownService { service_id: String }, + + /// The service exists but is disabled. + #[error("service {service_id:?} is disabled")] + ServiceDisabled { service_id: String }, + + /// No registered service owns the requested audience. + #[error("unknown audience {audience:?}")] + UnknownAudience { audience: String }, + + /// The principal lacks one or more requested permissions for the + /// target audience. + #[error("permissions not granted for audience {audience:?}: {missing:?}")] + PermissionsNotGranted { + audience: String, + missing: Vec, + }, + + /// A loaded topology policy does not declare this caller→audience edge. + #[error("service graph policy does not allow {caller:?} -> {audience:?}")] + EdgeNotAllowed { caller: String, audience: String }, + + /// A grant referenced a permission unknown to the target audience's + /// imported manifests (and was not explicitly marked custom). + #[error("permission {permission:?} is not a known permission of audience {audience:?}")] + UnknownPermission { + audience: String, + permission: String, + }, + + /// Underlying token signing/validation failure. + #[error("token error: {0}")] + Token(#[from] ras_authorization_token::TokenError), + + /// Storage failure. + #[error("authorization store error: {0}")] + Store(String), + + /// Invalid configuration or registration input. + #[error("invalid authorization configuration: {0}")] + InvalidConfig(String), +} diff --git a/crates/authorization/ras-authorization-core/src/issuer.rs b/crates/authorization/ras-authorization-core/src/issuer.rs new file mode 100644 index 0000000..e779e86 --- /dev/null +++ b/crates/authorization/ras-authorization-core/src/issuer.rs @@ -0,0 +1,309 @@ +//! The internal service token issuer. +//! +//! Issuance pipeline, every step fail-closed: +//! +//! 1. Verify the caller's service identity through the pluggable verifier. +//! 2. The service must be registered and enabled. +//! 3. The target audience must belong to a registered service. +//! 4. Requested permissions must all be granted to the service principal +//! *for that audience* (audience-scoped grants). +//! 5. If a service-graph policy is loaded, the caller→audience edge must be +//! declared and the requested permissions within the edge's ceiling. +//! 6. Mint a short-lived single-audience `ras_internal_access` JWT stamped +//! with the current `authz_version`. +//! +//! Every issuance, denial, and identity failure emits an audit event. + +use std::sync::Arc; + +use chrono::{DateTime, Duration, Utc}; +use ras_authorization_token::{JwkSet, KeyRing, RasClaims, SigningKey}; +use tokio::sync::RwLock; + +use crate::audit::{AuditEvent, AuditEventKind, AuditSink}; +use crate::error::AuthzError; +use crate::model::{Principal, ServiceGraphPolicy}; +use crate::store::AuthorizationStore; +use crate::verifier::{ServiceIdentityProof, ServiceIdentityVerifier}; + +/// A request for an internal service-to-service token. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct InternalTokenRequest { + /// The caller's identity proof. + pub proof: ServiceIdentityProof, + /// Target service audience. + pub audience: String, + /// Requested permissions (must be granted for that audience). + pub permissions: Vec, +} + +/// A successfully issued token. +#[derive(Debug, Clone, serde::Serialize)] +pub struct IssuedToken { + /// The signed JWT. Bearer credential — handle accordingly. + pub token: String, + pub expires_at: DateTime, + /// The claims that were signed (for logging-safe introspection; contains + /// no secret material). + #[serde(skip)] + pub claims: RasClaims, +} + +/// Builder for [`TokenIssuer`]. +pub struct TokenIssuerBuilder { + issuer: String, + keys: KeyRing, + store: Arc, + verifier: Arc, + audit: Arc, + token_ttl: Duration, +} + +impl TokenIssuerBuilder { + /// Override the internal token TTL (default 5 minutes). Internal tokens + /// are validated offline, so the TTL bounds revocation latency. + pub fn token_ttl(mut self, ttl: Duration) -> Self { + self.token_ttl = ttl; + self + } + + /// Attach an audit sink (default: drop events). + pub fn audit(mut self, audit: Arc) -> Self { + self.audit = audit; + self + } + + pub fn build(self) -> TokenIssuer { + TokenIssuer { + issuer: self.issuer, + keys: RwLock::new(self.keys), + store: self.store, + verifier: self.verifier, + audit: self.audit, + policy: RwLock::new(None), + token_ttl: self.token_ttl, + } + } +} + +/// The RAS authority's internal token issuer. +pub struct TokenIssuer { + issuer: String, + keys: RwLock, + store: Arc, + verifier: Arc, + audit: Arc, + policy: RwLock>, + token_ttl: Duration, +} + +impl TokenIssuer { + /// Start building an issuer. `issuer` is the `iss` claim value (the + /// authority's URL or stable identifier). + pub fn builder( + issuer: impl Into, + active_key: SigningKey, + store: Arc, + verifier: Arc, + ) -> TokenIssuerBuilder { + TokenIssuerBuilder { + issuer: issuer.into(), + keys: KeyRing::new(active_key), + store, + verifier, + audit: Arc::new(crate::audit::NoopAuditSink), + token_ttl: Duration::minutes(5), + } + } + + /// The `iss` value this issuer signs with. + pub fn issuer_id(&self) -> &str { + &self.issuer + } + + /// Load (or replace) the service-graph policy. With a policy loaded, + /// service-principal issuance outside the declared edges fails closed. + pub async fn load_policy(&self, policy: ServiceGraphPolicy) { + self.audit + .record( + AuditEvent::new( + AuditEventKind::PolicyLoaded, + format!( + "loaded service graph policy {} ({} edges)", + policy.policy_id, + policy.edges.len() + ), + ) + .with_target(policy.topology_name.clone()), + ) + .await; + *self.policy.write().await = Some(policy); + } + + /// The public JWKS for downstream validation. + pub async fn jwks(&self) -> JwkSet { + self.keys.read().await.jwks() + } + + /// Rotate the signing key. Outstanding tokens stay valid until expiry. + pub async fn rotate_key(&self, new_active: SigningKey) { + let kid = new_active.kid().to_string(); + self.keys.write().await.rotate(new_active); + self.audit + .record( + AuditEvent::new(AuditEventKind::SigningKeyRotated, "signing key rotated") + .with_target(kid), + ) + .await; + } + + /// Emergency-remove a retired verification key, immediately invalidating + /// tokens signed with it. + pub async fn remove_retired_key(&self, kid: &str) -> bool { + let removed = self.keys.write().await.remove_retired(kid); + if removed { + self.audit + .record( + AuditEvent::new( + AuditEventKind::SigningKeyRemoved, + "retired signing key removed", + ) + .with_target(kid.to_string()), + ) + .await; + } + removed + } + + /// Issue an internal service token. See the module docs for the + /// fail-closed pipeline. + pub async fn issue_internal_token( + &self, + request: InternalTokenRequest, + ) -> Result { + match self.try_issue(&request).await { + Ok(issued) => { + self.audit + .record( + AuditEvent::new( + AuditEventKind::TokenIssued, + format!( + "issued internal token for audience {:?} with permissions {:?}", + request.audience, request.permissions + ), + ) + .with_actor(request.proof.service_id.clone()) + .with_target(request.audience.clone()), + ) + .await; + Ok(issued) + } + Err(err) => { + let kind = match &err { + AuthzError::IdentityVerificationFailed { .. } => { + AuditEventKind::IdentityVerificationFailed + } + _ => AuditEventKind::TokenIssuanceDenied, + }; + self.audit + .record( + AuditEvent::new(kind, err.to_string()) + .with_actor(request.proof.service_id.clone()) + .with_target(request.audience.clone()), + ) + .await; + Err(err) + } + } + } + + async fn try_issue(&self, request: &InternalTokenRequest) -> Result { + // 1. Identity. + let identity = self.verifier.verify(&request.proof).await?; + + // 2. Registration. + let service = self + .store + .get_service(&identity.service_id) + .await? + .ok_or_else(|| AuthzError::UnknownService { + service_id: identity.service_id.clone(), + })?; + if !service.enabled { + return Err(AuthzError::ServiceDisabled { + service_id: service.service_id, + }); + } + + // 3. Target audience must exist. + if !self.store.audience_exists(&request.audience).await? { + return Err(AuthzError::UnknownAudience { + audience: request.audience.clone(), + }); + } + + // 4. Audience-scoped grants. + let principal = Principal::Service { + service_id: service.service_id.clone(), + }; + let resolved = self.store.resolve_permissions(&principal).await?; + let granted = resolved.get(&request.audience); + let missing: Vec = request + .permissions + .iter() + .filter(|permission| !granted.is_some_and(|set| set.contains(*permission))) + .cloned() + .collect(); + if !missing.is_empty() { + return Err(AuthzError::PermissionsNotGranted { + audience: request.audience.clone(), + missing, + }); + } + + // 5. Topology policy, when loaded. + if let Some(policy) = self.policy.read().await.as_ref() { + let edge = policy + .edge(&service.service_id, &request.audience) + .ok_or_else(|| AuthzError::EdgeNotAllowed { + caller: service.service_id.clone(), + audience: request.audience.clone(), + })?; + let outside: Vec = request + .permissions + .iter() + .filter(|permission| !edge.permissions.contains(*permission)) + .cloned() + .collect(); + if !outside.is_empty() { + return Err(AuthzError::PermissionsNotGranted { + audience: request.audience.clone(), + missing: outside, + }); + } + } + + // 6. Mint. + let authz_version = self.store.authz_version().await?; + let claims = RasClaims::internal_service( + self.issuer.clone(), + service.service_id, + principal.principal_kind(), + request.audience.clone(), + request.permissions.clone(), + self.token_ttl, + ) + .with_authz_version(authz_version); + + let token = self.keys.read().await.sign(&claims)?; + let expires_at = claims + .expires_at() + .ok_or_else(|| AuthzError::InvalidConfig("token expiry out of range".to_string()))?; + + Ok(IssuedToken { + token, + expires_at, + claims, + }) + } +} diff --git a/crates/authorization/ras-authorization-core/src/lib.rs b/crates/authorization/ras-authorization-core/src/lib.rs new file mode 100644 index 0000000..6dd7a60 --- /dev/null +++ b/crates/authorization/ras-authorization-core/src/lib.rs @@ -0,0 +1,57 @@ +//! RAS-native authorization control plane (issue #13, embedded mode). +//! +//! External identity providers authenticate humans; RAS owns application +//! authorization. This crate provides the control plane for internal +//! services: +//! +//! - **Model** ([`Principal`], [`ServiceRegistration`], +//! [`AudiencePermission`], [`RoleDefinition`]): grants are always scoped +//! by *target audience* — "principal X may use permission P at audience A" +//! — so identical permission strings on different services never satisfy +//! each other. +//! - **Store** ([`AuthorizationStore`], [`InMemoryAuthorizationStore`]): +//! service registry, roles, bindings, direct grants, and imported +//! permission manifests. Grants of permissions unknown to an audience's +//! manifests are rejected unless made through the explicit `grant_custom` +//! path. Every mutation bumps `authz_version`. +//! - **Identity** ([`ServiceIdentityVerifier`]): pluggable proof +//! verification. [`StaticSecretVerifier`] ships for dev/simple +//! deployments; production should use workload identity (Kubernetes SA +//! JWTs, SPIFFE, mTLS) through the same trait. +//! - **Issuer** ([`TokenIssuer`]): fail-closed internal token issuance — +//! identity, registration, audience existence, audience-scoped grants, +//! and (when loaded) topology [`ServiceGraphPolicy`] edges — minting +//! short-lived single-audience JWTs via `ras-authorization-token`, with +//! JWKS publication and key rotation. +//! - **Audit** ([`AuditSink`]): append-only events for registrations, +//! grants, issuance outcomes, and key changes; never containing secrets +//! or token values. +//! - **Embedded routes** ([`authority_router`]): `POST /auth/token` and +//! `GET /auth/jwks.json` mounted into any axum app — the default +//! single-process deployment preset. +//! - **Downstream validation** ([`RasTokenAuthProvider`]): an +//! `ras-auth-core` [`ras_auth_core::AuthProvider`] so existing generated +//! services accept RAS internal/gateway tokens unchanged. + +mod audit; +mod auth_provider; +mod error; +mod issuer; +mod model; +mod router; +mod store; +mod verifier; + +pub use audit::{AuditEvent, AuditEventKind, AuditSink, InMemoryAuditSink, NoopAuditSink}; +pub use auth_provider::RasTokenAuthProvider; +pub use error::AuthzError; +pub use issuer::{InternalTokenRequest, IssuedToken, TokenIssuer, TokenIssuerBuilder}; +pub use model::{ + AudiencePermission, Principal, ResolvedPermissions, RoleDefinition, ServiceEdge, + ServiceGraphPolicy, ServiceRegistration, +}; +pub use router::{TokenResponse, authority_router}; +pub use store::{AuthorizationStore, InMemoryAuthorizationStore}; +pub use verifier::{ + ServiceIdentityProof, ServiceIdentityVerifier, StaticSecretVerifier, VerifiedServiceIdentity, +}; diff --git a/crates/authorization/ras-authorization-core/src/model.rs b/crates/authorization/ras-authorization-core/src/model.rs new file mode 100644 index 0000000..8e9505f --- /dev/null +++ b/crates/authorization/ras-authorization-core/src/model.rs @@ -0,0 +1,154 @@ +//! Core authorization model: principals, registrations, grants, roles, and +//! the service-graph policy consumed from topology artifacts. + +use std::collections::{BTreeMap, BTreeSet}; + +use ras_authorization_token::PrincipalKind; +use serde::{Deserialize, Serialize}; + +/// A principal that can hold grants and request tokens. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Principal { + User { user_id: String }, + Service { service_id: String }, + ServiceAccount { service_account_id: String }, + Application { application_id: String }, +} + +impl Principal { + /// The token-claims principal kind for this principal. + pub fn principal_kind(&self) -> PrincipalKind { + match self { + Principal::User { .. } => PrincipalKind::User, + Principal::Service { .. } => PrincipalKind::Service, + Principal::ServiceAccount { .. } => PrincipalKind::ServiceAccount, + Principal::Application { .. } => PrincipalKind::Application, + } + } + + /// The principal's identifier (token `sub`). + pub fn id(&self) -> &str { + match self { + Principal::User { user_id } => user_id, + Principal::Service { service_id } => service_id, + Principal::ServiceAccount { service_account_id } => service_account_id, + Principal::Application { application_id } => application_id, + } + } +} + +/// A registered internal service. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ServiceRegistration { + /// Stable service id; the `sub` of tokens the service requests. + pub service_id: String, + pub display_name: String, + /// The audience this service validates on inbound tokens. + pub audience: String, + /// Disabled services can neither prove identity nor be issued tokens. + pub enabled: bool, +} + +/// One audience-scoped permission: "permission P at audience A". +/// +/// Grants are always scoped by target audience — `invoice:read` granted for +/// `invoice-service` says nothing about `invoice:read` at any other +/// audience. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub struct AudiencePermission { + pub audience: String, + pub permission: String, +} + +impl AudiencePermission { + pub fn new(audience: impl Into, permission: impl Into) -> Self { + Self { + audience: audience.into(), + permission: permission.into(), + } + } +} + +/// A named, reusable set of audience-scoped permissions. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RoleDefinition { + pub role_id: String, + pub permissions: BTreeSet, +} + +/// Resolved permissions for a principal, grouped by audience. +pub type ResolvedPermissions = BTreeMap>; + +/// Allowed service-to-service edges, typically generated by the topology +/// crate (#15). When loaded into the issuer, service-principal token +/// issuance outside the declared graph fails closed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ServiceGraphPolicy { + /// Artifact schema version. + pub schema_version: u32, + /// The topology this policy was generated from. + pub topology_name: String, + /// Stable artifact id for audit trails. + pub policy_id: String, + /// Declared edges. + pub edges: Vec, +} + +/// One allowed caller→audience edge with its permission ceiling. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ServiceEdge { + /// Calling service id. + pub caller_service_id: String, + /// Target service audience. + pub target_audience: String, + /// Permissions this edge may carry. Issuance requests outside this set + /// fail even if a broader grant exists. + pub permissions: BTreeSet, +} + +impl ServiceGraphPolicy { + /// Find the edge for a caller→audience pair. + pub fn edge(&self, caller_service_id: &str, target_audience: &str) -> Option<&ServiceEdge> { + self.edges.iter().find(|edge| { + edge.caller_service_id == caller_service_id && edge.target_audience == target_audience + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn principal_kind_and_id_map_per_variant() { + let principal = Principal::Service { + service_id: "billing".to_string(), + }; + assert_eq!(principal.principal_kind(), PrincipalKind::Service); + assert_eq!(principal.id(), "billing"); + + let user = Principal::User { + user_id: "alice".to_string(), + }; + assert_eq!(user.principal_kind(), PrincipalKind::User); + assert_eq!(user.id(), "alice"); + } + + #[test] + fn policy_edge_lookup_matches_caller_and_audience() { + let policy = ServiceGraphPolicy { + schema_version: 1, + topology_name: "internal-tools".to_string(), + policy_id: "internal-tools@1".to_string(), + edges: vec![ServiceEdge { + caller_service_id: "billing".to_string(), + target_audience: "invoice-service".to_string(), + permissions: BTreeSet::from(["invoice:read".to_string()]), + }], + }; + assert!(policy.edge("billing", "invoice-service").is_some()); + assert!(policy.edge("billing", "admin-service").is_none()); + assert!(policy.edge("admin", "invoice-service").is_none()); + } +} diff --git a/crates/authorization/ras-authorization-core/src/router.rs b/crates/authorization/ras-authorization-core/src/router.rs new file mode 100644 index 0000000..031b7d7 --- /dev/null +++ b/crates/authorization/ras-authorization-core/src/router.rs @@ -0,0 +1,82 @@ +//! Embedded-mode authority routes. +//! +//! [`authority_router`] mounts the token-issuance and JWKS endpoints in any +//! axum application, so a single process can be its own authority (the +//! default RAS deployment preset). Central-authority deployments serve the +//! same router from a dedicated service. + +use std::sync::Arc; + +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; + +use crate::error::AuthzError; +use crate::issuer::{InternalTokenRequest, TokenIssuer}; + +/// Response body for a successful token issuance. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct TokenResponse { + pub token: String, + pub expires_at: chrono::DateTime, +} + +#[derive(serde::Serialize)] +struct ErrorResponse { + error: &'static str, +} + +fn error_response(err: &AuthzError) -> Response { + // Coarse error codes only: issuance callers learn *that* they were + // denied, not the authorization topology behind the decision. + let (status, code) = match err { + AuthzError::IdentityVerificationFailed { .. } => { + (StatusCode::UNAUTHORIZED, "identity_verification_failed") + } + AuthzError::UnknownService { .. } + | AuthzError::ServiceDisabled { .. } + | AuthzError::UnknownAudience { .. } + | AuthzError::PermissionsNotGranted { .. } + | AuthzError::EdgeNotAllowed { .. } + | AuthzError::UnknownPermission { .. } => (StatusCode::FORBIDDEN, "issuance_denied"), + AuthzError::Token(_) | AuthzError::Store(_) | AuthzError::InvalidConfig(_) => { + (StatusCode::INTERNAL_SERVER_ERROR, "internal_error") + } + }; + (status, Json(ErrorResponse { error: code })).into_response() +} + +async fn issue_token( + State(issuer): State>, + Json(request): Json, +) -> Response { + match issuer.issue_internal_token(request).await { + Ok(issued) => Json(TokenResponse { + token: issued.token, + expires_at: issued.expires_at, + }) + .into_response(), + Err(err) => error_response(&err), + } +} + +async fn jwks(State(issuer): State>) -> Response { + Json(issuer.jwks().await).into_response() +} + +/// Build the authority router: +/// +/// - `POST /auth/token` — internal service token issuance +/// ([`InternalTokenRequest`] → [`TokenResponse`]) +/// - `GET /auth/jwks.json` — public JWKS for downstream validation +/// +/// Merge into an existing app for embedded mode, or serve standalone for a +/// central authority. +pub fn authority_router(issuer: Arc) -> Router { + Router::new() + .route("/auth/token", post(issue_token)) + .route("/auth/jwks.json", get(jwks)) + .with_state(issuer) +} diff --git a/crates/authorization/ras-authorization-core/src/store.rs b/crates/authorization/ras-authorization-core/src/store.rs new file mode 100644 index 0000000..99dac89 --- /dev/null +++ b/crates/authorization/ras-authorization-core/src/store.rs @@ -0,0 +1,317 @@ +//! Authorization storage: the read trait used by the issuer plus an +//! in-memory implementation with the embedded-mode management API. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; + +use async_trait::async_trait; +use ras_permission_manifest::PermissionManifest; +use tokio::sync::RwLock; + +use crate::error::AuthzError; +use crate::model::{ + AudiencePermission, Principal, ResolvedPermissions, RoleDefinition, ServiceRegistration, +}; + +/// Read interface the token issuer needs. Production deployments implement +/// this over their database; [`InMemoryAuthorizationStore`] serves embedded +/// mode, tests, and examples. +#[async_trait] +pub trait AuthorizationStore: Send + Sync { + async fn get_service( + &self, + service_id: &str, + ) -> Result, AuthzError>; + + /// Whether any registered service owns `audience`. + async fn audience_exists(&self, audience: &str) -> Result; + + /// All permissions the principal holds, grouped by audience (direct + /// grants plus role bindings). + async fn resolve_permissions( + &self, + principal: &Principal, + ) -> Result; + + /// Monotonic version, bumped on every authorization mutation. Stamped + /// into issued tokens as `authz_version`. + async fn authz_version(&self) -> Result; +} + +#[derive(Default)] +struct StoreState { + services: HashMap, + /// audience -> permissions known from imported manifests. + known_permissions: HashMap>, + roles: HashMap, + role_bindings: HashMap>, + direct_grants: HashMap>, + version: u64, +} + +impl StoreState { + fn bump(&mut self) { + self.version += 1; + } + + fn check_known(&self, grant: &AudiencePermission) -> Result<(), AuthzError> { + let known = self + .known_permissions + .get(&grant.audience) + .is_some_and(|permissions| permissions.contains(&grant.permission)); + if known { + Ok(()) + } else { + Err(AuthzError::UnknownPermission { + audience: grant.audience.clone(), + permission: grant.permission.clone(), + }) + } + } +} + +/// In-memory authorization store with the embedded-mode management API. +/// +/// Grants default to manifest-known permissions only: import each service's +/// generated permission manifest, then grant. Permissions outside any +/// imported manifest require the explicit `*_custom` methods, keeping +/// ad-hoc strings visible at the call site. +#[derive(Default)] +pub struct InMemoryAuthorizationStore { + state: RwLock, +} + +impl InMemoryAuthorizationStore { + pub fn new() -> Self { + Self::default() + } + + /// Register (or replace) a service. The audience must be unique across + /// registered services. + pub async fn register_service( + &self, + registration: ServiceRegistration, + ) -> Result<(), AuthzError> { + if registration.service_id.is_empty() || registration.audience.is_empty() { + return Err(AuthzError::InvalidConfig( + "service_id and audience must be non-empty".to_string(), + )); + } + let mut state = self.state.write().await; + let audience_taken = state.services.values().any(|service| { + service.audience == registration.audience + && service.service_id != registration.service_id + }); + if audience_taken { + return Err(AuthzError::InvalidConfig(format!( + "audience {:?} is already registered to another service", + registration.audience + ))); + } + state + .services + .insert(registration.service_id.clone(), registration); + state.bump(); + Ok(()) + } + + /// Disable a service: it can no longer be issued tokens. + pub async fn set_service_enabled( + &self, + service_id: &str, + enabled: bool, + ) -> Result<(), AuthzError> { + let mut state = self.state.write().await; + let service = + state + .services + .get_mut(service_id) + .ok_or_else(|| AuthzError::UnknownService { + service_id: service_id.to_string(), + })?; + service.enabled = enabled; + state.bump(); + Ok(()) + } + + /// Import a generated permission manifest as the known-permission + /// vocabulary for `audience`. Subsequent grants for that audience are + /// validated against it. + pub async fn import_manifest( + &self, + audience: impl Into, + manifest: &PermissionManifest, + ) -> Result { + let audience = audience.into(); + let permissions: BTreeSet = manifest + .permissions() + .into_iter() + .map(str::to_string) + .collect(); + let count = permissions.len(); + let mut state = self.state.write().await; + state + .known_permissions + .entry(audience) + .or_default() + .extend(permissions); + state.bump(); + Ok(count) + } + + /// Define (or replace) a role. Every permission must be known from an + /// imported manifest. + pub async fn define_role(&self, role: RoleDefinition) -> Result<(), AuthzError> { + let mut state = self.state.write().await; + for grant in &role.permissions { + state.check_known(grant)?; + } + state.roles.insert(role.role_id.clone(), role); + state.bump(); + Ok(()) + } + + /// Bind a role to a principal. + pub async fn bind_role( + &self, + principal: Principal, + role_id: impl Into, + ) -> Result<(), AuthzError> { + let role_id = role_id.into(); + let mut state = self.state.write().await; + if !state.roles.contains_key(&role_id) { + return Err(AuthzError::InvalidConfig(format!( + "role {role_id:?} is not defined" + ))); + } + state + .role_bindings + .entry(principal) + .or_default() + .insert(role_id); + state.bump(); + Ok(()) + } + + /// Grant a manifest-known permission directly to a principal. + pub async fn grant( + &self, + principal: Principal, + grant: AudiencePermission, + ) -> Result<(), AuthzError> { + let mut state = self.state.write().await; + state.check_known(&grant)?; + state + .direct_grants + .entry(principal) + .or_default() + .insert(grant); + state.bump(); + Ok(()) + } + + /// Grant a permission that is *not* part of any imported manifest. + /// Explicitly named so custom/manual permissions stay visible. + pub async fn grant_custom( + &self, + principal: Principal, + grant: AudiencePermission, + ) -> Result<(), AuthzError> { + let mut state = self.state.write().await; + state + .direct_grants + .entry(principal) + .or_default() + .insert(grant); + state.bump(); + Ok(()) + } + + /// Revoke a direct grant. Returns whether it existed. + pub async fn revoke( + &self, + principal: &Principal, + grant: &AudiencePermission, + ) -> Result { + let mut state = self.state.write().await; + let removed = state + .direct_grants + .get_mut(principal) + .is_some_and(|grants| grants.remove(grant)); + if removed { + state.bump(); + } + Ok(removed) + } + + /// Remove a role binding. Returns whether it existed. + pub async fn unbind_role( + &self, + principal: &Principal, + role_id: &str, + ) -> Result { + let mut state = self.state.write().await; + let removed = state + .role_bindings + .get_mut(principal) + .is_some_and(|roles| roles.remove(role_id)); + if removed { + state.bump(); + } + Ok(removed) + } +} + +#[async_trait] +impl AuthorizationStore for InMemoryAuthorizationStore { + async fn get_service( + &self, + service_id: &str, + ) -> Result, AuthzError> { + Ok(self.state.read().await.services.get(service_id).cloned()) + } + + async fn audience_exists(&self, audience: &str) -> Result { + Ok(self + .state + .read() + .await + .services + .values() + .any(|service| service.audience == audience)) + } + + async fn resolve_permissions( + &self, + principal: &Principal, + ) -> Result { + let state = self.state.read().await; + let mut resolved: ResolvedPermissions = BTreeMap::new(); + + let mut add = |grant: &AudiencePermission| { + resolved + .entry(grant.audience.clone()) + .or_default() + .insert(grant.permission.clone()); + }; + + if let Some(grants) = state.direct_grants.get(principal) { + for grant in grants { + add(grant); + } + } + if let Some(roles) = state.role_bindings.get(principal) { + for role_id in roles { + if let Some(role) = state.roles.get(role_id) { + for grant in &role.permissions { + add(grant); + } + } + } + } + Ok(resolved) + } + + async fn authz_version(&self) -> Result { + Ok(self.state.read().await.version) + } +} diff --git a/crates/authorization/ras-authorization-core/src/verifier.rs b/crates/authorization/ras-authorization-core/src/verifier.rs new file mode 100644 index 0000000..30edf1f --- /dev/null +++ b/crates/authorization/ras-authorization-core/src/verifier.rs @@ -0,0 +1,181 @@ +//! Pluggable service identity verification. +//! +//! A service proves its identity to the RAS authority through a +//! [`ServiceIdentityVerifier`]. The proof payload is verifier-specific JSON, +//! so production adapters (Kubernetes service-account JWTs, SPIFFE/SPIRE, +//! mTLS, cloud workload identity) can plug in without changing the issuer. +//! +//! [`StaticSecretVerifier`] is the development/simple-deployment verifier: +//! a static per-service secret. It is deliberately the *only* verifier +//! shipped here; production deployments should prefer workload identity. + +use async_trait::async_trait; +use std::collections::HashMap; +use tokio::sync::RwLock; + +use crate::error::AuthzError; + +/// A service's identity proof: which service it claims to be plus +/// verifier-specific evidence. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ServiceIdentityProof { + pub service_id: String, + /// Verifier-specific payload. For [`StaticSecretVerifier`]: + /// `{"client_secret": "..."}`. Treated as sensitive: never logged. + pub proof: serde_json::Value, +} + +/// A successfully verified service identity. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedServiceIdentity { + pub service_id: String, +} + +/// Verifies service identity proofs. +/// +/// Implementations must fail closed and must not leak why verification +/// failed (wrong service vs wrong credential) beyond +/// [`AuthzError::IdentityVerificationFailed`]. +#[async_trait] +pub trait ServiceIdentityVerifier: Send + Sync { + async fn verify( + &self, + proof: &ServiceIdentityProof, + ) -> Result; +} + +/// Development/simple-mode verifier: per-service static client secrets. +/// +/// **Not for production.** Static secrets are long-lived bearer credentials +/// with no rotation, binding, or replay story; use a workload-identity +/// verifier in real deployments. Secrets must be at least 32 bytes and are +/// compared in constant time. +#[derive(Default)] +pub struct StaticSecretVerifier { + secrets: RwLock>>, +} + +impl StaticSecretVerifier { + pub fn new() -> Self { + Self::default() + } + + /// Register a service's static secret. + pub async fn register( + &self, + service_id: impl Into, + secret: impl Into>, + ) -> Result<(), AuthzError> { + let secret = secret.into(); + if secret.len() < 32 { + return Err(AuthzError::InvalidConfig( + "static service secrets must be at least 32 bytes".to_string(), + )); + } + self.secrets.write().await.insert(service_id.into(), secret); + Ok(()) + } +} + +/// Constant-time byte comparison: timing reveals only the lengths. +fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { + if left.len() != right.len() { + return false; + } + let mut diff = 0u8; + for (a, b) in left.iter().zip(right.iter()) { + diff |= a ^ b; + } + diff == 0 +} + +#[async_trait] +impl ServiceIdentityVerifier for StaticSecretVerifier { + async fn verify( + &self, + proof: &ServiceIdentityProof, + ) -> Result { + let failed = || AuthzError::IdentityVerificationFailed { + service_id: proof.service_id.clone(), + }; + + let presented = proof + .proof + .get("client_secret") + .and_then(|value| value.as_str()) + .ok_or_else(failed)?; + + let secrets = self.secrets.read().await; + let expected = secrets.get(&proof.service_id).ok_or_else(failed)?; + if constant_time_eq(presented.as_bytes(), expected) { + Ok(VerifiedServiceIdentity { + service_id: proof.service_id.clone(), + }) + } else { + Err(failed()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SECRET: &str = "a-static-service-secret-of-32-bytes!"; + + fn proof(service_id: &str, secret: &str) -> ServiceIdentityProof { + ServiceIdentityProof { + service_id: service_id.to_string(), + proof: serde_json::json!({ "client_secret": secret }), + } + } + + #[tokio::test] + async fn correct_secret_verifies() { + let verifier = StaticSecretVerifier::new(); + verifier + .register("billing", SECRET.as_bytes()) + .await + .unwrap(); + let identity = verifier.verify(&proof("billing", SECRET)).await.unwrap(); + assert_eq!(identity.service_id, "billing"); + } + + #[tokio::test] + async fn wrong_secret_unknown_service_and_malformed_proof_fail_identically() { + let verifier = StaticSecretVerifier::new(); + verifier + .register("billing", SECRET.as_bytes()) + .await + .unwrap(); + + for bad in [ + proof("billing", "wrong-secret-that-is-also-32-bytes!"), + proof("unknown-service", SECRET), + ServiceIdentityProof { + service_id: "billing".to_string(), + proof: serde_json::json!({}), + }, + ] { + let err = verifier.verify(&bad).await.unwrap_err(); + assert!(matches!(err, AuthzError::IdentityVerificationFailed { .. })); + } + } + + #[tokio::test] + async fn short_secrets_are_rejected_at_registration() { + let verifier = StaticSecretVerifier::new(); + let err = verifier + .register("billing", b"short".to_vec()) + .await + .unwrap_err(); + assert!(matches!(err, AuthzError::InvalidConfig(_))); + } + + #[test] + fn constant_time_eq_basics() { + assert!(constant_time_eq(b"abc", b"abc")); + assert!(!constant_time_eq(b"abc", b"abd")); + assert!(!constant_time_eq(b"abc", b"abcd")); + } +} diff --git a/crates/authorization/ras-authorization-core/tests/authority_tests.rs b/crates/authorization/ras-authorization-core/tests/authority_tests.rs new file mode 100644 index 0000000..f73ed56 --- /dev/null +++ b/crates/authorization/ras-authorization-core/tests/authority_tests.rs @@ -0,0 +1,553 @@ +//! Control-plane tests: store semantics, fail-closed issuance, topology +//! policy, key rotation, audit, and the embedded authority router. + +use std::collections::BTreeSet; +use std::sync::Arc; + +use ras_authorization_core::{ + AudiencePermission, AuditEventKind, AuthorizationStore, AuthzError, InMemoryAuditSink, + InMemoryAuthorizationStore, InternalTokenRequest, Principal, RoleDefinition, ServiceEdge, + ServiceGraphPolicy, ServiceIdentityProof, ServiceRegistration, StaticSecretVerifier, + TokenIssuer, authority_router, +}; +use ras_authorization_token::{ + AudiencePolicy, JwkSet, SigningKey, TokenType, TokenValidator, ValidationOptions, +}; +use ras_permission_manifest::{ + AuthRequirementInfo, OperationKind, OperationPermissions, PermissionManifest, + ServicePermissions, TransportKind, WireTarget, +}; + +const ISSUER: &str = "https://auth.internal"; +const BILLING_SECRET: &str = "billing-service-static-secret-32b!!"; + +/// Build a manifest whose operations require the given permissions. +fn manifest(service: &str, permissions: &[&str]) -> PermissionManifest { + let operations = permissions + .iter() + .map(|permission| OperationPermissions { + operation_id: format!("op_{permission}"), + operation_name: format!("op_{permission}"), + kind: OperationKind::JsonRpcMethod, + wire: WireTarget::JsonRpc { + method: permission.to_string(), + }, + auth: AuthRequirementInfo::from_permission_groups([[*permission]]), + version: None, + canonical_operation_id: None, + }) + .collect(); + PermissionManifest::from_services([ServicePermissions { + service_name: service.to_string(), + transport: TransportKind::JsonRpc, + operations, + }]) +} + +struct Authority { + store: Arc, + verifier: Arc, + audit: Arc, + issuer: Arc, +} + +/// Standard fixture: billing-service and invoice-service registered, +/// invoice manifest imported, billing granted invoice:read at +/// invoice-service. +async fn authority() -> Authority { + let store = Arc::new(InMemoryAuthorizationStore::new()); + let verifier = Arc::new(StaticSecretVerifier::new()); + let audit = Arc::new(InMemoryAuditSink::new()); + + for (id, audience) in [ + ("billing-service", "billing-service"), + ("invoice-service", "invoice-service"), + ] { + store + .register_service(ServiceRegistration { + service_id: id.to_string(), + display_name: id.to_string(), + audience: audience.to_string(), + enabled: true, + }) + .await + .unwrap(); + } + verifier + .register("billing-service", BILLING_SECRET.as_bytes()) + .await + .unwrap(); + + store + .import_manifest( + "invoice-service", + &manifest("InvoiceService", &["invoice:read", "invoice:write"]), + ) + .await + .unwrap(); + store + .grant( + Principal::Service { + service_id: "billing-service".to_string(), + }, + AudiencePermission::new("invoice-service", "invoice:read"), + ) + .await + .unwrap(); + + let issuer = Arc::new( + TokenIssuer::builder( + ISSUER, + SigningKey::generate_es256("k1"), + store.clone(), + verifier.clone(), + ) + .audit(audit.clone()) + .build(), + ); + + Authority { + store, + verifier, + audit, + issuer, + } +} + +fn billing_proof(secret: &str) -> ServiceIdentityProof { + ServiceIdentityProof { + service_id: "billing-service".to_string(), + proof: serde_json::json!({ "client_secret": secret }), + } +} + +fn token_request(permissions: &[&str]) -> InternalTokenRequest { + InternalTokenRequest { + proof: billing_proof(BILLING_SECRET), + audience: "invoice-service".to_string(), + permissions: permissions.iter().map(|p| p.to_string()).collect(), + } +} + +fn invoice_validator(jwks: JwkSet) -> TokenValidator { + TokenValidator::new( + jwks, + ValidationOptions::new( + ISSUER, + AudiencePolicy::Exact("invoice-service".to_string()), + vec![TokenType::InternalService], + ), + ) +} + +// --- Store semantics --- + +#[tokio::test] +async fn grants_are_audience_scoped() { + let authority = authority().await; + // billing has invoice:read at invoice-service. The same permission + // string at billing-service must not satisfy anything. + let principal = Principal::Service { + service_id: "billing-service".to_string(), + }; + let resolved = authority + .store + .resolve_permissions(&principal) + .await + .unwrap(); + assert!(resolved["invoice-service"].contains("invoice:read")); + assert!(!resolved.contains_key("billing-service")); +} + +#[tokio::test] +async fn roles_and_direct_grants_merge_in_resolution() { + let authority = authority().await; + let principal = Principal::User { + user_id: "alice".to_string(), + }; + authority + .store + .define_role(RoleDefinition { + role_id: "invoice-admin".to_string(), + permissions: BTreeSet::from([ + AudiencePermission::new("invoice-service", "invoice:read"), + AudiencePermission::new("invoice-service", "invoice:write"), + ]), + }) + .await + .unwrap(); + authority + .store + .bind_role(principal.clone(), "invoice-admin") + .await + .unwrap(); + authority + .store + .grant( + principal.clone(), + AudiencePermission::new("invoice-service", "invoice:read"), + ) + .await + .unwrap(); + + let resolved = authority + .store + .resolve_permissions(&principal) + .await + .unwrap(); + assert_eq!(resolved["invoice-service"].len(), 2); +} + +#[tokio::test] +async fn unknown_permissions_are_rejected_unless_custom() { + let authority = authority().await; + let principal = Principal::User { + user_id: "alice".to_string(), + }; + + let err = authority + .store + .grant( + principal.clone(), + AudiencePermission::new("invoice-service", "not-in-any-manifest"), + ) + .await + .unwrap_err(); + assert!(matches!(err, AuthzError::UnknownPermission { .. })); + + // Known permission at the wrong audience is also unknown. + let err = authority + .store + .grant( + principal.clone(), + AudiencePermission::new("billing-service", "invoice:read"), + ) + .await + .unwrap_err(); + assert!(matches!(err, AuthzError::UnknownPermission { .. })); + + // The explicit custom path works. + authority + .store + .grant_custom( + principal, + AudiencePermission::new("invoice-service", "not-in-any-manifest"), + ) + .await + .unwrap(); +} + +#[tokio::test] +async fn duplicate_audiences_are_rejected() { + let authority = authority().await; + let err = authority + .store + .register_service(ServiceRegistration { + service_id: "impostor".to_string(), + display_name: "impostor".to_string(), + audience: "invoice-service".to_string(), + enabled: true, + }) + .await + .unwrap_err(); + assert!(matches!(err, AuthzError::InvalidConfig(_))); +} + +#[tokio::test] +async fn mutations_bump_authz_version() { + let authority = authority().await; + let before = authority.store.authz_version().await.unwrap(); + authority + .store + .grant( + Principal::User { + user_id: "alice".to_string(), + }, + AudiencePermission::new("invoice-service", "invoice:write"), + ) + .await + .unwrap(); + assert!(authority.store.authz_version().await.unwrap() > before); +} + +// --- Issuance --- + +#[tokio::test] +async fn issuance_happy_path_validates_via_jwks() { + let authority = authority().await; + let issued = authority + .issuer + .issue_internal_token(token_request(&["invoice:read"])) + .await + .unwrap(); + + let claims = invoice_validator(authority.issuer.jwks().await) + .validate(&issued.token) + .unwrap(); + assert_eq!(claims.sub, "billing-service"); + assert_eq!(claims.aud.as_deref(), Some("invoice-service")); + assert_eq!(claims.permissions, vec!["invoice:read"]); + assert_eq!(claims.token_type, TokenType::InternalService); + assert_eq!( + claims.authz_version, + Some(authority.store.authz_version().await.unwrap()) + ); +} + +#[tokio::test] +async fn wrong_secret_fails_identity_verification() { + let authority = authority().await; + let mut request = token_request(&["invoice:read"]); + request.proof = billing_proof("wrong-secret-that-is-32-bytes-long!"); + let err = authority + .issuer + .issue_internal_token(request) + .await + .unwrap_err(); + assert!(matches!(err, AuthzError::IdentityVerificationFailed { .. })); +} + +#[tokio::test] +async fn unregistered_service_is_rejected_even_with_valid_proof() { + let authority = authority().await; + // ghost-service can prove identity but is not in the registry. + authority + .verifier + .register("ghost-service", BILLING_SECRET.as_bytes()) + .await + .unwrap(); + let request = InternalTokenRequest { + proof: ServiceIdentityProof { + service_id: "ghost-service".to_string(), + proof: serde_json::json!({ "client_secret": BILLING_SECRET }), + }, + audience: "invoice-service".to_string(), + permissions: vec![], + }; + let err = authority + .issuer + .issue_internal_token(request) + .await + .unwrap_err(); + assert!(matches!(err, AuthzError::UnknownService { .. })); +} + +#[tokio::test] +async fn disabled_service_is_rejected() { + let authority = authority().await; + authority + .store + .set_service_enabled("billing-service", false) + .await + .unwrap(); + let err = authority + .issuer + .issue_internal_token(token_request(&["invoice:read"])) + .await + .unwrap_err(); + assert!(matches!(err, AuthzError::ServiceDisabled { .. })); +} + +#[tokio::test] +async fn unknown_audience_is_rejected() { + let authority = authority().await; + let mut request = token_request(&[]); + request.audience = "no-such-service".to_string(); + let err = authority + .issuer + .issue_internal_token(request) + .await + .unwrap_err(); + assert!(matches!(err, AuthzError::UnknownAudience { .. })); +} + +#[tokio::test] +async fn ungranted_permissions_are_rejected_with_missing_list() { + let authority = authority().await; + let err = authority + .issuer + .issue_internal_token(token_request(&["invoice:read", "invoice:write"])) + .await + .unwrap_err(); + let AuthzError::PermissionsNotGranted { missing, .. } = err else { + panic!("expected PermissionsNotGranted"); + }; + assert_eq!(missing, vec!["invoice:write"]); +} + +#[tokio::test] +async fn revoked_grant_denies_new_tokens() { + let authority = authority().await; + authority + .store + .revoke( + &Principal::Service { + service_id: "billing-service".to_string(), + }, + &AudiencePermission::new("invoice-service", "invoice:read"), + ) + .await + .unwrap(); + let err = authority + .issuer + .issue_internal_token(token_request(&["invoice:read"])) + .await + .unwrap_err(); + assert!(matches!(err, AuthzError::PermissionsNotGranted { .. })); +} + +// --- Topology policy --- + +#[tokio::test] +async fn loaded_policy_constrains_edges_and_permission_ceilings() { + let authority = authority().await; + // Grant billing invoice:write too, so only the policy constrains it. + authority + .store + .grant( + Principal::Service { + service_id: "billing-service".to_string(), + }, + AudiencePermission::new("invoice-service", "invoice:write"), + ) + .await + .unwrap(); + + authority + .issuer + .load_policy(ServiceGraphPolicy { + schema_version: 1, + topology_name: "internal-tools".to_string(), + policy_id: "internal-tools@1".to_string(), + edges: vec![ServiceEdge { + caller_service_id: "billing-service".to_string(), + target_audience: "invoice-service".to_string(), + permissions: BTreeSet::from(["invoice:read".to_string()]), + }], + }) + .await; + + // Within the edge: fine. + authority + .issuer + .issue_internal_token(token_request(&["invoice:read"])) + .await + .unwrap(); + + // Granted but outside the edge ceiling: denied. + let err = authority + .issuer + .issue_internal_token(token_request(&["invoice:write"])) + .await + .unwrap_err(); + assert!(matches!(err, AuthzError::PermissionsNotGranted { .. })); + + // Edge not declared at all: denied. + let mut request = token_request(&[]); + request.audience = "billing-service".to_string(); + let err = authority + .issuer + .issue_internal_token(request) + .await + .unwrap_err(); + assert!(matches!(err, AuthzError::EdgeNotAllowed { .. })); +} + +// --- Key rotation --- + +#[tokio::test] +async fn rotation_keeps_outstanding_tokens_valid_and_removal_kills_them() { + let authority = authority().await; + let old_token = authority + .issuer + .issue_internal_token(token_request(&["invoice:read"])) + .await + .unwrap(); + + authority + .issuer + .rotate_key(SigningKey::generate_es256("k2")) + .await; + let new_token = authority + .issuer + .issue_internal_token(token_request(&["invoice:read"])) + .await + .unwrap(); + + let validator = invoice_validator(authority.issuer.jwks().await); + assert!(validator.validate(&old_token.token).is_ok()); + assert!(validator.validate(&new_token.token).is_ok()); + + assert!(authority.issuer.remove_retired_key("k1").await); + let validator = invoice_validator(authority.issuer.jwks().await); + assert!(validator.validate(&old_token.token).is_err()); + assert!(validator.validate(&new_token.token).is_ok()); +} + +// --- Audit --- + +#[tokio::test] +async fn audit_records_outcomes_and_never_secrets() { + let authority = authority().await; + authority + .issuer + .issue_internal_token(token_request(&["invoice:read"])) + .await + .unwrap(); + let mut bad = token_request(&["invoice:read"]); + bad.proof = billing_proof("wrong-secret-that-is-32-bytes-long!"); + let _ = authority.issuer.issue_internal_token(bad).await; + let _ = authority + .issuer + .issue_internal_token(token_request(&["invoice:write"])) + .await; + + let events = authority.audit.events().await; + let kinds: Vec<_> = events.iter().map(|event| event.kind.clone()).collect(); + assert!(kinds.contains(&AuditEventKind::TokenIssued)); + assert!(kinds.contains(&AuditEventKind::IdentityVerificationFailed)); + assert!(kinds.contains(&AuditEventKind::TokenIssuanceDenied)); + + // No secret or token material in any event. + let serialized = serde_json::to_string(&events).unwrap(); + assert!(!serialized.contains(BILLING_SECRET)); + assert!(!serialized.contains("wrong-secret")); + assert!(!serialized.contains("eyJ"), "JWTs must not be audited"); +} + +// --- Embedded router --- + +#[tokio::test] +async fn authority_router_issues_and_serves_jwks() { + let authority = authority().await; + let app = authority_router(authority.issuer.clone()); + let server = axum_test::TestServer::new(app).unwrap(); + + // JWKS endpoint. + let jwks: JwkSet = server.get("/auth/jwks.json").await.json(); + assert_eq!(jwks.keys.len(), 1); + + // Issuance. + let response = server + .post("/auth/token") + .json(&token_request(&["invoice:read"])) + .await; + response.assert_status_ok(); + let body: serde_json::Value = response.json(); + let token = body["token"].as_str().unwrap(); + let claims = invoice_validator(jwks).validate(token).unwrap(); + assert_eq!(claims.sub, "billing-service"); + + // Identity failure -> 401 with a coarse error code. + let mut bad = token_request(&[]); + bad.proof = billing_proof("wrong-secret-that-is-32-bytes-long!"); + let response = server.post("/auth/token").json(&bad).await; + response.assert_status(axum_test::http::StatusCode::UNAUTHORIZED); + + // Authorization failure -> 403. + let response = server + .post("/auth/token") + .json(&token_request(&["invoice:write"])) + .await; + response.assert_status(axum_test::http::StatusCode::FORBIDDEN); +} diff --git a/crates/authorization/ras-authorization-gateway/Cargo.toml b/crates/authorization/ras-authorization-gateway/Cargo.toml new file mode 100644 index 0000000..741b623 --- /dev/null +++ b/crates/authorization/ras-authorization-gateway/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "ras-authorization-gateway" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +description = "Optional RAS auth gateway: validates web sessions locally, narrows permissions to the target audience, and forwards short-lived single-audience tokens to backend services" +keywords = ["api", "auth", "gateway", "proxy", "tokens"] +categories = ["authentication", "web-programming"] +license = "MIT OR Apache-2.0" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" +readme = "README.md" + +[dependencies] +ras-authorization-token = { path = "../ras-authorization-token", version = "0.1.0" } +ras-transport-core = { path = "../../core/ras-transport-core", version = "0.1.0" } + +axum = { workspace = true } +bytes = { workspace = true } +chrono = { workspace = true } +cookie = { workspace = true } +futures-util = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +toml = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +async-trait = { workspace = true } +axum-test = { workspace = true } +futures = { workspace = true } diff --git a/crates/authorization/ras-authorization-gateway/README.md b/crates/authorization/ras-authorization-gateway/README.md new file mode 100644 index 0000000..a4e268b --- /dev/null +++ b/crates/authorization/ras-authorization-gateway/README.md @@ -0,0 +1,29 @@ +# ras-authorization-gateway + +Optional RAS auth gateway (issue #14): a token-narrowing reverse proxy for +browser frontends that fan out to multiple backend services. Deploy it +*behind* your existing ingress — it is an application-layer token exchanger, +not a general-purpose ingress controller. + +- Validates `ras_web_session` tokens locally via JWKS — no authority call on + the hot path. +- Deterministic longest-prefix, segment-aligned route matching; duplicate + prefixes fail validation; unmatched routes fail closed. +- Mints short-lived single-audience `ras_gateway_access` tokens containing + only the target audience's session permissions; never invents or widens; + never forwards the original session token. +- Derived tokens never outlive their session; the derived-token cache + (keyed by session/subject/audience/authz-version) is an optimization only. +- Strips inbound `Authorization`, `Cookie`, `Host`, and hop-by-hop headers; + streams request/response bodies without buffering. +- Sessions lacking the target audience's permissions fail closed unless the + route is explicitly `authenticated_only`. +- **v1 limitation:** connection upgrades (WebSocket) fail closed with 501; + bidirectional RAS services must be reached directly for now. +- Consumes generated topology gateway profiles + (`GatewayConfig::from_profile_toml`) with startup validation of + deployment-provided upstream bindings; hand-written `RouteRule`s remain + supported. + +Backends validate derived tokens with `backend_validation_options` + the +gateway JWKS, or `ras-authorization-core`'s `RasTokenAuthProvider`. diff --git a/crates/authorization/ras-authorization-gateway/src/config.rs b/crates/authorization/ras-authorization-gateway/src/config.rs new file mode 100644 index 0000000..45e82da --- /dev/null +++ b/crates/authorization/ras-authorization-gateway/src/config.rs @@ -0,0 +1,317 @@ +//! Gateway configuration: route rules, the compiled route table, and +//! consumption of generated topology gateway profiles. + +use std::collections::BTreeMap; + +use chrono::Duration; +use serde::Deserialize; + +use crate::error::GatewayError; + +/// One route: a path prefix mapped to a backend audience and upstream. +#[derive(Debug, Clone)] +pub struct RouteRule { + /// Path prefix. Matching is segment-aligned and longest-prefix-wins; + /// `/api` matches `/api` and `/api/x` but never `/api-private`. + pub prefix: String, + /// The backend audience derived tokens are narrowed to. + pub audience: String, + /// Upstream base URL (deployment-specific binding). + pub upstream: String, + /// Allow requests whose session has *no* permissions for this audience. + /// Defaults to false: missing target-audience permissions fail closed. + pub authenticated_only: bool, +} + +impl RouteRule { + pub fn new( + prefix: impl Into, + audience: impl Into, + upstream: impl Into, + ) -> Self { + Self { + prefix: prefix.into(), + audience: audience.into(), + upstream: upstream.into(), + authenticated_only: false, + } + } + + /// Mark this route as authenticated-only (empty permission set allowed). + pub fn authenticated_only(mut self) -> Self { + self.authenticated_only = true; + self + } +} + +/// Top-level gateway configuration. +#[derive(Debug, Clone)] +pub struct GatewayConfig { + /// Expected issuer of inbound web sessions. + pub session_issuer: String, + /// Issuer stamped into derived backend tokens (the gateway acts as + /// delegated RAS auth infrastructure under this name). + pub derived_token_issuer: String, + /// Route rules. Validated into a [`RouteTable`]. + pub routes: Vec, + /// Cookie consulted for the web session when no `Authorization` header + /// is present. + pub session_cookie: String, + /// Lifetime of derived backend tokens (default 2 minutes; always also + /// bounded by the session expiry). + pub derived_token_ttl: Duration, + /// Upper bound on derived-token cache reuse (default 60 seconds). + pub cache_max_ttl: Duration, +} + +impl GatewayConfig { + pub fn new( + session_issuer: impl Into, + derived_token_issuer: impl Into, + routes: Vec, + ) -> Self { + Self { + session_issuer: session_issuer.into(), + derived_token_issuer: derived_token_issuer.into(), + routes, + session_cookie: "ras_session".to_string(), + derived_token_ttl: Duration::minutes(2), + cache_max_ttl: Duration::seconds(60), + } + } + + /// Build a config from a generated topology gateway profile (TOML) plus + /// deployment-provided upstream bindings (audience → base URL). + /// + /// Startup-validates that every profile route resolves to an upstream; + /// missing bindings fail closed with the full missing list. + pub fn from_profile_toml( + session_issuer: impl Into, + derived_token_issuer: impl Into, + profile_toml: &str, + upstreams: &BTreeMap, + ) -> Result { + let profile: GatewayProfile = toml::from_str(profile_toml).map_err(|err| { + GatewayError::InvalidConfig(format!("invalid gateway profile: {err}")) + })?; + + let missing: Vec = profile + .routes + .values() + .filter(|route| !upstreams.contains_key(&route.audience)) + .map(|route| route.audience.clone()) + .collect(); + if !missing.is_empty() { + return Err(GatewayError::InvalidConfig(format!( + "no upstream binding for audiences {missing:?}" + ))); + } + + let routes = profile + .routes + .into_iter() + .map(|(prefix, route)| RouteRule { + upstream: upstreams[&route.audience].clone(), + prefix, + audience: route.audience, + authenticated_only: route.authenticated_only, + }) + .collect(); + Ok(Self::new(session_issuer, derived_token_issuer, routes)) + } +} + +/// A generated gateway profile artifact (the topology crate emits this). +#[derive(Debug, Clone, Deserialize)] +pub struct GatewayProfile { + pub schema_version: u32, + pub topology: String, + pub profile: String, + pub profile_id: String, + /// Route prefix → audience mapping. Upstream bindings are deliberately + /// absent: they are deployment-specific. + pub routes: BTreeMap, +} + +/// One route entry in a generated profile. +#[derive(Debug, Clone, Deserialize)] +pub struct ProfileRoute { + pub audience: String, + #[serde(default)] + pub authenticated_only: bool, +} + +/// Compiled, validated route table with deterministic longest-prefix +/// matching. +#[derive(Debug)] +pub struct RouteTable { + /// Sorted by prefix length descending, so the first match wins. + routes: Vec, +} + +impl RouteTable { + /// Validate and compile route rules: + /// + /// - prefixes must start with `/`; trailing slashes are normalized away + /// (except the root route `/`) + /// - duplicate prefixes within one profile fail validation + pub fn new(rules: Vec) -> Result { + if rules.is_empty() { + return Err(GatewayError::InvalidConfig( + "gateway requires at least one route".to_string(), + )); + } + let mut routes = Vec::with_capacity(rules.len()); + for mut rule in rules { + if !rule.prefix.starts_with('/') { + return Err(GatewayError::InvalidConfig(format!( + "route prefix {:?} must start with '/'", + rule.prefix + ))); + } + if rule.prefix != "/" { + rule.prefix = rule.prefix.trim_end_matches('/').to_string(); + } + if rule.audience.is_empty() || rule.upstream.is_empty() { + return Err(GatewayError::InvalidConfig(format!( + "route {:?} requires a non-empty audience and upstream", + rule.prefix + ))); + } + routes.push(rule); + } + for (index, rule) in routes.iter().enumerate() { + if routes[..index] + .iter() + .any(|other| other.prefix == rule.prefix) + { + return Err(GatewayError::InvalidConfig(format!( + "conflicting routes for prefix {:?}", + rule.prefix + ))); + } + } + routes.sort_by_key(|rule| std::cmp::Reverse(rule.prefix.len())); + Ok(Self { routes }) + } + + /// Match a request path: longest prefix wins; matches are + /// segment-aligned. Unmatched paths return `None` (fail closed). + pub fn match_path(&self, path: &str) -> Option<&RouteRule> { + self.routes.iter().find(|rule| { + if rule.prefix == "/" { + return true; + } + path == rule.prefix + || path + .strip_prefix(rule.prefix.as_str()) + .is_some_and(|rest| rest.starts_with('/')) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rule(prefix: &str, audience: &str) -> RouteRule { + RouteRule::new(prefix, audience, format!("http://{audience}:3000")) + } + + #[test] + fn longest_prefix_wins_and_matching_is_segment_aligned() { + let table = RouteTable::new(vec![ + rule("/invoices", "invoice-service"), + rule("/invoices/admin", "admin-service"), + rule("/billing", "billing-service"), + ]) + .unwrap(); + + assert_eq!( + table.match_path("/invoices/123").unwrap().audience, + "invoice-service" + ); + assert_eq!( + table.match_path("/invoices/admin/users").unwrap().audience, + "admin-service" + ); + assert_eq!( + table.match_path("/invoices").unwrap().audience, + "invoice-service" + ); + // Segment alignment: /invoices-extra does not match /invoices. + assert!(table.match_path("/invoices-extra/1").is_none()); + // Unmatched paths fail closed. + assert!(table.match_path("/unknown").is_none()); + } + + #[test] + fn duplicate_prefixes_fail_validation() { + let err = RouteTable::new(vec![ + rule("/invoices", "invoice-service"), + rule("/invoices/", "billing-service"), + ]) + .unwrap_err(); + assert!(matches!(err, GatewayError::InvalidConfig(_))); + } + + #[test] + fn invalid_prefixes_and_empty_tables_are_rejected() { + assert!(matches!( + RouteTable::new(vec![rule("invoices", "invoice-service")]).unwrap_err(), + GatewayError::InvalidConfig(_) + )); + assert!(matches!( + RouteTable::new(vec![]).unwrap_err(), + GatewayError::InvalidConfig(_) + )); + } + + #[test] + fn root_route_matches_everything() { + let table = RouteTable::new(vec![rule("/", "app"), rule("/api", "api-service")]).unwrap(); + assert_eq!(table.match_path("/api/x").unwrap().audience, "api-service"); + assert_eq!(table.match_path("/anything").unwrap().audience, "app"); + } + + #[test] + fn profile_loading_binds_upstreams_and_fails_closed_on_missing() { + let profile = r#" + schema_version = 1 + topology = "internal-tools" + profile = "public_web" + profile_id = "internal-tools/public_web@1" + + [routes."/invoices"] + audience = "invoice-service" + + [routes."/billing"] + audience = "billing-service" + authenticated_only = true + "#; + + let mut upstreams = BTreeMap::new(); + upstreams.insert( + "invoice-service".to_string(), + "http://invoice:3000".to_string(), + ); + let err = GatewayConfig::from_profile_toml("iss", "gw", profile, &upstreams).unwrap_err(); + assert!( + matches!(err, GatewayError::InvalidConfig(message) if message.contains("billing-service")) + ); + + upstreams.insert( + "billing-service".to_string(), + "http://billing:3000".to_string(), + ); + let config = GatewayConfig::from_profile_toml("iss", "gw", profile, &upstreams).unwrap(); + assert_eq!(config.routes.len(), 2); + let billing = config + .routes + .iter() + .find(|route| route.audience == "billing-service") + .unwrap(); + assert!(billing.authenticated_only); + assert_eq!(billing.upstream, "http://billing:3000"); + } +} diff --git a/crates/authorization/ras-authorization-gateway/src/error.rs b/crates/authorization/ras-authorization-gateway/src/error.rs new file mode 100644 index 0000000..812ed40 --- /dev/null +++ b/crates/authorization/ras-authorization-gateway/src/error.rs @@ -0,0 +1,44 @@ +//! Gateway errors and their HTTP mappings. + +use thiserror::Error; + +/// Errors raised while routing, validating, deriving, or proxying. +/// +/// Mapped to coarse HTTP statuses by the proxy layer; token values never +/// appear in any variant. +#[derive(Debug, Error)] +pub enum GatewayError { + /// Configuration/profile validation failure (startup time). + #[error("invalid gateway configuration: {0}")] + InvalidConfig(String), + + /// No route matches the request path. Fails closed as 404. + #[error("no route matches the request path")] + RouteNotFound, + + /// The request carried no session credential. + #[error("missing web session")] + MissingSession, + + /// The web session failed validation. + #[error("invalid web session")] + InvalidSession(#[source] ras_authorization_token::TokenError), + + /// The validated session holds no permissions for the route's audience + /// (and the route is not declared authenticated-only). + #[error("session has no permissions for audience {audience:?}")] + NoPermissionsForAudience { audience: String }, + + /// Derived-token signing failed. + #[error("token derivation failed")] + Derivation(#[source] ras_authorization_token::TokenError), + + /// The upstream call failed. + #[error("upstream error: {0}")] + Upstream(#[from] ras_transport_core::TransportError), + + /// WebSocket/connection-upgrade proxying is not supported in v1; such + /// requests fail closed. + #[error("connection upgrades are not supported by the gateway")] + UpgradeNotSupported, +} diff --git a/crates/authorization/ras-authorization-gateway/src/gateway.rs b/crates/authorization/ras-authorization-gateway/src/gateway.rs new file mode 100644 index 0000000..85f8c6a --- /dev/null +++ b/crates/authorization/ras-authorization-gateway/src/gateway.rs @@ -0,0 +1,209 @@ +//! The gateway core: session validation, audience narrowing, and the +//! derived-token cache. +//! +//! The gateway is trusted auth infrastructure with one narrow power: it may +//! *narrow* a valid web session into a short-lived single-audience backend +//! token. It never invents permissions, never widens audiences, and never +//! forwards the original session token. + +use std::collections::HashMap; +use std::sync::{Mutex, RwLock}; + +use chrono::{DateTime, Duration, Utc}; +use ras_authorization_token::{ + AudiencePolicy, JwkSet, KeyResolver, KeyRing, RasClaims, SigningKey, TokenType, TokenValidator, + ValidationOptions, +}; + +use crate::config::{GatewayConfig, RouteRule, RouteTable}; +use crate::error::GatewayError; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct DerivedCacheKey { + session_jti: String, + subject: String, + audience: String, + authz_version: Option, +} + +#[derive(Clone)] +struct CachedDerived { + token: String, + reuse_until: DateTime, +} + +/// A derived single-audience backend token. +#[derive(Clone)] +pub struct DerivedToken { + /// The signed `ras_gateway_access` JWT. Bearer credential. + pub token: String, + pub expires_at: DateTime, +} + +impl std::fmt::Debug for DerivedToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DerivedToken") + .field("token", &"") + .field("expires_at", &self.expires_at) + .finish() + } +} + +/// Validates web sessions and derives audience-narrowed backend tokens. +pub struct AuthGateway { + session_validator: TokenValidator>, + keys: RwLock, + table: RouteTable, + cache: Mutex>, + derived_token_issuer: String, + derived_token_ttl: Duration, + cache_max_ttl: Duration, + session_cookie: String, +} + +impl AuthGateway { + /// Build a gateway. + /// + /// - `session_keys` resolves the RAS authority's web-session signing + /// keys (typically a fetched JWKS or shared `KeyRing`). + /// - `derived_signing_key` signs gateway-derived tokens. It is gateway + /// auth infrastructure: publish [`AuthGateway::jwks`] for backends and + /// rotate via [`AuthGateway::rotate_key`]. + pub fn new( + config: GatewayConfig, + session_keys: std::sync::Arc, + derived_signing_key: SigningKey, + ) -> Result { + let table = RouteTable::new(config.routes)?; + let session_validator = TokenValidator::new( + session_keys, + ValidationOptions::new( + config.session_issuer, + AudiencePolicy::Absent, + vec![TokenType::WebSession], + ), + ); + Ok(Self { + session_validator, + keys: RwLock::new(KeyRing::new(derived_signing_key)), + table, + cache: Mutex::new(HashMap::new()), + derived_token_issuer: config.derived_token_issuer, + derived_token_ttl: config.derived_token_ttl, + cache_max_ttl: config.cache_max_ttl, + session_cookie: config.session_cookie, + }) + } + + /// The cookie name consulted for web sessions. + pub fn session_cookie(&self) -> &str { + &self.session_cookie + } + + /// The route table. + pub fn routes(&self) -> &RouteTable { + &self.table + } + + /// JWKS for the gateway's derived-token signing keys. Backends validate + /// `ras_gateway_access` tokens against this. + pub fn jwks(&self) -> JwkSet { + self.keys.read().expect("gateway key lock poisoned").jwks() + } + + /// Rotate the derived-token signing key. + pub fn rotate_key(&self, new_active: SigningKey) { + self.keys + .write() + .expect("gateway key lock poisoned") + .rotate(new_active); + } + + /// Validate an inbound web session token. + pub fn validate_session(&self, token: &str) -> Result { + self.session_validator + .validate(token) + .map_err(GatewayError::InvalidSession) + } + + /// Derive (or reuse from cache) a single-audience backend token for a + /// validated session and matched route. + /// + /// The derived token contains exactly the route audience's permissions + /// from the session — never more. Sessions without permissions for the + /// audience fail closed unless the route is declared authenticated-only. + pub fn derive_for_route( + &self, + session: &RasClaims, + route: &RouteRule, + ) -> Result { + let permissions = match session.permissions_for_audience(&route.audience) { + Some(permissions) if !permissions.is_empty() => permissions.to_vec(), + _ if route.authenticated_only => Vec::new(), + _ => { + return Err(GatewayError::NoPermissionsForAudience { + audience: route.audience.clone(), + }); + } + }; + + let now = Utc::now(); + let session_expires_at = session.expires_at().ok_or_else(|| { + GatewayError::InvalidSession(ras_authorization_token::TokenError::InvalidClaims( + "session expiry out of range".to_string(), + )) + })?; + + let key = DerivedCacheKey { + session_jti: session.jti.clone(), + subject: session.sub.clone(), + audience: route.audience.clone(), + authz_version: session.authz_version, + }; + + { + let cache = self.cache.lock().expect("gateway cache lock poisoned"); + if let Some(cached) = cache.get(&key) + && now < cached.reuse_until + { + return Ok(DerivedToken { + token: cached.token.clone(), + expires_at: cached.reuse_until, + }); + } + } + + // Derived expiry: bounded by both the configured TTL and the + // session's own expiry — a derived token never outlives its session. + let expires_at = std::cmp::min(now + self.derived_token_ttl, session_expires_at); + let mut claims = RasClaims::gateway_access( + self.derived_token_issuer.clone(), + session.sub.clone(), + route.audience.clone(), + permissions, + session.authz_version, + self.derived_token_ttl, + ); + claims.exp = expires_at.timestamp(); + + let token = self + .keys + .read() + .expect("gateway key lock poisoned") + .sign(&claims) + .map_err(GatewayError::Derivation)?; + + let reuse_until = std::cmp::min(expires_at, now + self.cache_max_ttl); + let mut cache = self.cache.lock().expect("gateway cache lock poisoned"); + cache.retain(|_, cached| cached.reuse_until > now); + cache.insert( + key, + CachedDerived { + token: token.clone(), + reuse_until, + }, + ); + + Ok(DerivedToken { token, expires_at }) + } +} diff --git a/crates/authorization/ras-authorization-gateway/src/lib.rs b/crates/authorization/ras-authorization-gateway/src/lib.rs new file mode 100644 index 0000000..b3b7459 --- /dev/null +++ b/crates/authorization/ras-authorization-gateway/src/lib.rs @@ -0,0 +1,66 @@ +//! Optional RAS auth gateway for multi-service browser frontends (issue +//! #14). +//! +//! For one service, embedded auth (issue #13) needs no gateway. When a +//! browser frontend fans out to several backend services, forwarding the +//! full multi-audience web session everywhere leaks cross-service permission +//! data and forces every backend to understand audience maps. The gateway +//! fixes that: +//! +//! ```text +//! browser (ras_web_session cookie/bearer) +//! -> gateway validates the session locally (JWKS, no authority call) +//! -> longest-prefix route -> target audience +//! -> session permissions narrowed to that audience only +//! -> short-lived single-audience ras_gateway_access token minted/cached +//! -> request proxied with only the derived bearer attached +//! backend validates the simple single-audience token via the gateway JWKS +//! ``` +//! +//! Invariants enforced by construction and tests: +//! +//! - The original session token is never forwarded; inbound +//! `Authorization`/`Cookie`/hop-by-hop headers are stripped. +//! - Derived tokens carry exactly one audience and only that audience's +//! session permissions — never invented, never widened. +//! - Routes outside the table, sessions without the target audience's +//! permissions (unless declared authenticated-only), and connection +//! upgrades (WebSocket, v1) all fail closed. +//! - Derived tokens never outlive their session; the cache is a pure +//! optimization keyed by session/subject/audience/authz-version. +//! +//! Deploy the gateway *behind* your existing ingress/load balancer — it is +//! an application-layer token exchanger, not a general-purpose ingress. +//! Route/audience profiles can be hand-written ([`RouteRule`]) or consumed +//! from generated topology artifacts +//! ([`GatewayConfig::from_profile_toml`]). +//! +//! Backends validate derived tokens with +//! [`backend_validation_options`] plus the gateway's JWKS — or directly via +//! `ras-authorization-core`'s `RasTokenAuthProvider` for generated RAS +//! services. + +mod config; +mod error; +mod gateway; +mod proxy; + +pub use config::{GatewayConfig, GatewayProfile, ProfileRoute, RouteRule, RouteTable}; +pub use error::GatewayError; +pub use gateway::{AuthGateway, DerivedToken}; +pub use proxy::gateway_router; + +use ras_authorization_token::{AudiencePolicy, TokenType, ValidationOptions}; + +/// Validation options for a backend accepting gateway-derived tokens: +/// pinned issuer, exact audience, and the `ras_gateway_access` token type. +pub fn backend_validation_options( + gateway_issuer: impl Into, + audience: impl Into, +) -> ValidationOptions { + ValidationOptions::new( + gateway_issuer, + AudiencePolicy::Exact(audience.into()), + vec![TokenType::GatewayAccess], + ) +} diff --git a/crates/authorization/ras-authorization-gateway/src/proxy.rs b/crates/authorization/ras-authorization-gateway/src/proxy.rs new file mode 100644 index 0000000..5215652 --- /dev/null +++ b/crates/authorization/ras-authorization-gateway/src/proxy.rs @@ -0,0 +1,164 @@ +//! The proxying layer: an axum service that validates, narrows, and +//! forwards. +//! +//! Header hygiene: inbound `Authorization`, `Cookie`, `Host`, and all +//! hop-by-hop headers are stripped before proxying; the only credential a +//! backend ever receives is the gateway-derived bearer. Request and response +//! bodies stream without buffering. Connection upgrades (WebSocket) fail +//! closed in v1. + +use std::sync::Arc; + +use axum::Router; +use axum::body::Body; +use axum::extract::{Request, State}; +use axum::http::{HeaderMap, HeaderName, StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use futures_util::TryStreamExt; +use ras_transport_core::{ + HttpTransport, RequestBody, TransportError, TransportRequest, byte_stream_from, +}; + +use crate::error::GatewayError; +use crate::gateway::AuthGateway; + +/// Hop-by-hop headers (RFC 9110 §7.6.1) that must not be proxied. +const HOP_BY_HOP: [HeaderName; 8] = [ + header::CONNECTION, + HeaderName::from_static("keep-alive"), + header::PROXY_AUTHENTICATE, + header::PROXY_AUTHORIZATION, + header::TE, + header::TRAILER, + header::TRANSFER_ENCODING, + header::UPGRADE, +]; + +struct ProxyState { + gateway: Arc, + upstream: Arc, +} + +/// Build the gateway as an axum [`Router`]. +/// +/// `upstream` executes the proxied requests: [`ras_transport_core::ReqwestTransport`] +/// in production, a fake or `AxumTestTransport` in tests. +pub fn gateway_router(gateway: Arc, upstream: Arc) -> Router { + Router::new() + .fallback(proxy_handler) + .with_state(Arc::new(ProxyState { gateway, upstream })) +} + +fn error_response(err: &GatewayError) -> Response { + let status = match err { + GatewayError::RouteNotFound => StatusCode::NOT_FOUND, + GatewayError::MissingSession | GatewayError::InvalidSession(_) => StatusCode::UNAUTHORIZED, + GatewayError::NoPermissionsForAudience { .. } => StatusCode::FORBIDDEN, + GatewayError::UpgradeNotSupported => StatusCode::NOT_IMPLEMENTED, + GatewayError::Upstream(_) => StatusCode::BAD_GATEWAY, + GatewayError::InvalidConfig(_) | GatewayError::Derivation(_) => { + StatusCode::INTERNAL_SERVER_ERROR + } + }; + // Audit hook: outcomes are traced without token values. + tracing::debug!(error = %err, status = %status, "gateway request rejected"); + status.into_response() +} + +/// Extract the web session credential: `Authorization: Bearer` first, then +/// the configured session cookie. +fn extract_session_token(headers: &HeaderMap, cookie_name: &str) -> Option { + if let Some(value) = headers.get(header::AUTHORIZATION) + && let Ok(value) = value.to_str() + && let Some(token) = value.strip_prefix("Bearer ") + { + return Some(token.to_string()); + } + for value in headers.get_all(header::COOKIE) { + let Ok(value) = value.to_str() else { continue }; + for piece in cookie::Cookie::split_parse(value).flatten() { + if piece.name() == cookie_name { + return Some(piece.value().to_string()); + } + } + } + None +} + +async fn proxy_handler(State(state): State>, request: Request) -> Response { + match proxy(state, request).await { + Ok(response) => response, + Err(err) => error_response(&err), + } +} + +async fn proxy(state: Arc, request: Request) -> Result { + // v1: connection upgrades (WebSocket) fail closed. Bidirectional RAS + // services must be reached directly or through a future upgrade-aware + // gateway version. + if request.headers().contains_key(header::UPGRADE) { + return Err(GatewayError::UpgradeNotSupported); + } + + let route = state + .gateway + .routes() + .match_path(request.uri().path()) + .ok_or(GatewayError::RouteNotFound)? + .clone(); + + let token = extract_session_token(request.headers(), state.gateway.session_cookie()) + .ok_or(GatewayError::MissingSession)?; + let session = state.gateway.validate_session(&token)?; + let derived = state.gateway.derive_for_route(&session, &route)?; + + // Build the outbound request: upstream base + original path and query, + // no rewriting. + let path_and_query = request + .uri() + .path_and_query() + .map(|pq| pq.as_str()) + .unwrap_or("/"); + let url = format!("{}{}", route.upstream.trim_end_matches('/'), path_and_query); + + let mut outbound = TransportRequest::new(request.method().clone(), url); + for (name, value) in request.headers() { + if HOP_BY_HOP.contains(name) + || name == header::AUTHORIZATION + || name == header::COOKIE + || name == header::HOST + || name == header::CONTENT_LENGTH + { + continue; + } + outbound.headers.append(name.clone(), value.clone()); + } + // The derived bearer is the only credential the backend sees. Set + // fail-closed: an unencodable token aborts rather than proxying + // unauthenticated. + let outbound = outbound + .bearer(&derived.token) + .map_err(GatewayError::Upstream)?; + + let body_stream = request + .into_body() + .into_data_stream() + .map_err(|err| TransportError::Body(err.to_string())); + let outbound = outbound.body(RequestBody::Stream(byte_stream_from(body_stream))); + + let upstream_response = state.upstream.execute(outbound).await?; + + let mut response = Response::builder().status(upstream_response.status()); + if let Some(headers) = response.headers_mut() { + for (name, value) in upstream_response.headers() { + if HOP_BY_HOP.contains(name) || name == header::CONTENT_LENGTH { + continue; + } + headers.append(name.clone(), value.clone()); + } + } + let body = Body::from_stream(upstream_response.into_body_stream()); + response + .body(body) + .map_err(|err| GatewayError::InvalidConfig(format!("response build failed: {err}"))) +} diff --git a/crates/authorization/ras-authorization-gateway/tests/gateway_tests.rs b/crates/authorization/ras-authorization-gateway/tests/gateway_tests.rs new file mode 100644 index 0000000..bc5df92 --- /dev/null +++ b/crates/authorization/ras-authorization-gateway/tests/gateway_tests.rs @@ -0,0 +1,437 @@ +//! Gateway tests: narrowing semantics, derived-token caching, and the full +//! proxy path with header hygiene. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use async_trait::async_trait; +use bytes::Bytes; +use chrono::Duration; +use futures_util::StreamExt; +use ras_authorization_gateway::{ + AuthGateway, GatewayConfig, GatewayError, RouteRule, backend_validation_options, gateway_router, +}; +use ras_authorization_token::{ + KeyResolver, KeyRing, RasClaims, SigningKey, TokenType, TokenValidator, +}; +use ras_transport_core::http::{HeaderMap, StatusCode}; +use ras_transport_core::{ + HttpTransport, RequestBody, TransportError, TransportRequest, TransportResponse, + byte_stream_from, +}; +use tokio::sync::Mutex; + +const AUTH_ISSUER: &str = "https://auth.internal"; +const GATEWAY_ISSUER: &str = "https://gateway.internal"; + +fn session_permissions() -> BTreeMap> { + BTreeMap::from([ + ( + "invoice-service".to_string(), + vec!["invoice:read".to_string(), "invoice:approve".to_string()], + ), + ( + "billing-service".to_string(), + vec!["billing:read".to_string()], + ), + ]) +} + +fn web_session(authority: &KeyRing, ttl: Duration) -> (String, RasClaims) { + let claims = RasClaims::web_session(AUTH_ISSUER, "alice", session_permissions(), ttl) + .with_authz_version(7); + (authority.sign(&claims).unwrap(), claims) +} + +fn routes() -> Vec { + vec![ + RouteRule::new("/invoices", "invoice-service", "http://invoice:3000"), + RouteRule::new("/billing", "billing-service", "http://billing:3000"), + RouteRule::new("/admin", "admin-service", "http://admin:3000"), + RouteRule::new("/health", "health-service", "http://health:3000").authenticated_only(), + ] +} + +fn build_gateway(authority: &KeyRing) -> AuthGateway { + AuthGateway::new( + GatewayConfig::new(AUTH_ISSUER, GATEWAY_ISSUER, routes()), + Arc::new(authority.jwks()) as Arc, + SigningKey::generate_es256("gw-1"), + ) + .unwrap() +} + +fn route(gateway: &AuthGateway, path: &str) -> RouteRule { + gateway.routes().match_path(path).unwrap().clone() +} + +// --- Narrowing semantics --- + +#[test] +fn derived_token_is_single_audience_with_only_that_audiences_permissions() { + let authority = KeyRing::new(SigningKey::generate_es256("auth-1")); + let gateway = build_gateway(&authority); + let (_, session) = web_session(&authority, Duration::minutes(30)); + + let derived = gateway + .derive_for_route(&session, &route(&gateway, "/invoices/1")) + .unwrap(); + + let validator = TokenValidator::new( + gateway.jwks(), + backend_validation_options(GATEWAY_ISSUER, "invoice-service"), + ); + let claims = validator.validate(&derived.token).unwrap(); + assert_eq!(claims.token_type, TokenType::GatewayAccess); + assert_eq!(claims.sub, "alice"); + assert_eq!(claims.aud.as_deref(), Some("invoice-service")); + assert_eq!(claims.permissions, vec!["invoice:read", "invoice:approve"]); + assert!( + claims.audience_permissions.is_none(), + "no cross-audience data" + ); + assert_eq!(claims.authz_version, Some(7)); + + // The same token must NOT validate for another audience. + let wrong = TokenValidator::new( + gateway.jwks(), + backend_validation_options(GATEWAY_ISSUER, "billing-service"), + ); + assert!(wrong.validate(&derived.token).is_err()); +} + +#[test] +fn missing_audience_permissions_fail_closed_unless_authenticated_only() { + let authority = KeyRing::new(SigningKey::generate_es256("auth-1")); + let gateway = build_gateway(&authority); + let (_, session) = web_session(&authority, Duration::minutes(30)); + + // No permissions for admin-service in the session. + let err = gateway + .derive_for_route(&session, &route(&gateway, "/admin")) + .unwrap_err(); + assert!(matches!( + err, + GatewayError::NoPermissionsForAudience { audience } if audience == "admin-service" + )); + + // Authenticated-only route: empty permission set is allowed. + let derived = gateway + .derive_for_route(&session, &route(&gateway, "/health")) + .unwrap(); + let claims = TokenValidator::new( + gateway.jwks(), + backend_validation_options(GATEWAY_ISSUER, "health-service"), + ) + .validate(&derived.token) + .unwrap(); + assert!(claims.permissions.is_empty()); +} + +#[test] +fn derived_tokens_are_cached_per_session_audience_and_version() { + let authority = KeyRing::new(SigningKey::generate_es256("auth-1")); + let gateway = build_gateway(&authority); + let (_, session) = web_session(&authority, Duration::minutes(30)); + + let invoice_route = route(&gateway, "/invoices"); + let first = gateway.derive_for_route(&session, &invoice_route).unwrap(); + let second = gateway.derive_for_route(&session, &invoice_route).unwrap(); + assert_eq!(first.token, second.token, "cache reuses the derived token"); + + // Different audience: different token. + let billing = gateway + .derive_for_route(&session, &route(&gateway, "/billing")) + .unwrap(); + assert_ne!(first.token, billing.token); + + // Bumped authz version: cache miss, fresh token. + let mut session_v8 = session.clone(); + session_v8.authz_version = Some(8); + let rederived = gateway + .derive_for_route(&session_v8, &invoice_route) + .unwrap(); + assert_ne!(first.token, rederived.token); + + // Different session (new jti): cache miss. + let (_, other_session) = web_session(&authority, Duration::minutes(30)); + let other = gateway + .derive_for_route(&other_session, &invoice_route) + .unwrap(); + assert_ne!(first.token, other.token); +} + +#[test] +fn derived_token_never_outlives_the_session() { + let authority = KeyRing::new(SigningKey::generate_es256("auth-1")); + let gateway = build_gateway(&authority); + // Session expiring in 30 seconds; derived TTL default is 2 minutes. + let (_, session) = web_session(&authority, Duration::seconds(30)); + + let derived = gateway + .derive_for_route(&session, &route(&gateway, "/invoices")) + .unwrap(); + assert!(derived.expires_at <= session.expires_at().unwrap()); +} + +#[test] +fn non_session_tokens_are_rejected_as_sessions() { + let authority = KeyRing::new(SigningKey::generate_es256("auth-1")); + let gateway = build_gateway(&authority); + + // An internal service token is not a web session. + let internal = RasClaims::internal_service( + AUTH_ISSUER, + "billing-service", + ras_authorization_token::PrincipalKind::Service, + "invoice-service", + vec!["invoice:read".to_string()], + Duration::minutes(5), + ); + let token = authority.sign(&internal).unwrap(); + assert!(matches!( + gateway.validate_session(&token).unwrap_err(), + GatewayError::InvalidSession(_) + )); + assert!(gateway.validate_session("garbage").is_err()); +} + +// --- Proxy path --- + +struct Captured { + url: String, + method: String, + authorization: Option, + cookie: Option, + connection: Option, + custom: Option, + body: Vec, +} + +#[derive(Default)] +struct FakeUpstream { + captured: Mutex>, +} + +#[async_trait] +impl HttpTransport for FakeUpstream { + async fn execute( + &self, + request: TransportRequest, + ) -> Result { + let header = |name: &str| { + request + .headers + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::to_string) + }; + let body = match request.body { + RequestBody::Empty => Vec::new(), + RequestBody::Bytes(bytes) => bytes.to_vec(), + RequestBody::Stream(mut stream) => { + let mut collected = Vec::new(); + while let Some(chunk) = stream.next().await { + collected.extend_from_slice(&chunk?); + } + collected + } + }; + self.captured.lock().await.push(Captured { + url: request.url.clone(), + method: request.method.to_string(), + authorization: header("authorization"), + cookie: header("cookie"), + connection: header("connection"), + custom: header("x-custom"), + body, + }); + + let mut headers = HeaderMap::new(); + headers.insert("x-upstream", "yes".parse().unwrap()); + headers.insert("transfer-encoding", "chunked".parse().unwrap()); + Ok(TransportResponse::new( + StatusCode::CREATED, + headers, + byte_stream_from(futures::stream::iter(vec![Ok(Bytes::from_static( + b"upstream-body", + ))])), + )) + } +} + +struct ProxyFixture { + server: axum_test::TestServer, + upstream: Arc, + gateway: Arc, + session_token: String, +} + +fn proxy_fixture() -> ProxyFixture { + let authority = KeyRing::new(SigningKey::generate_es256("auth-1")); + let gateway = Arc::new(build_gateway(&authority)); + let upstream = Arc::new(FakeUpstream::default()); + let server = + axum_test::TestServer::new(gateway_router(gateway.clone(), upstream.clone())).unwrap(); + let (session_token, _) = web_session(&authority, Duration::minutes(30)); + ProxyFixture { + server, + upstream, + gateway, + session_token, + } +} + +#[tokio::test] +async fn proxies_with_derived_bearer_and_strips_inbound_credentials() { + let fixture = proxy_fixture(); + let response = fixture + .server + .post("/invoices/123") + .add_query_param("verbose", "1") + .authorization_bearer(&fixture.session_token) + .add_header("cookie", "tracking=abc; ras_session=stale") + .add_header("x-custom", "forwarded") + .add_header("connection", "keep-alive") + .bytes(Bytes::from_static(b"request-payload")) + .await; + + response.assert_status(StatusCode::CREATED); + assert_eq!(response.header("x-upstream"), "yes"); + response.assert_text("upstream-body"); + + let captured = fixture.upstream.captured.lock().await; + let request = &captured[0]; + assert_eq!(request.url, "http://invoice:3000/invoices/123?verbose=1"); + assert_eq!(request.method, "POST"); + assert_eq!(request.body, b"request-payload"); + assert_eq!(request.custom.as_deref(), Some("forwarded")); + assert!(request.cookie.is_none(), "inbound cookies are stripped"); + assert!(request.connection.is_none(), "hop-by-hop headers stripped"); + + // The backend got a derived token, not the session token. + let bearer = request.authorization.as_deref().unwrap(); + let derived = bearer.strip_prefix("Bearer ").unwrap(); + assert_ne!(derived, fixture.session_token); + let claims = TokenValidator::new( + fixture.gateway.jwks(), + backend_validation_options(GATEWAY_ISSUER, "invoice-service"), + ) + .validate(derived) + .unwrap(); + assert_eq!(claims.permissions, vec!["invoice:read", "invoice:approve"]); +} + +#[tokio::test] +async fn cookie_sessions_work_and_are_not_forwarded() { + let fixture = proxy_fixture(); + let response = fixture + .server + .get("/billing/summary") + .add_header( + "cookie", + format!("ras_session={}; theme=dark", fixture.session_token), + ) + .await; + response.assert_status(StatusCode::CREATED); + + let captured = fixture.upstream.captured.lock().await; + assert!(captured[0].cookie.is_none()); + let bearer = captured[0].authorization.as_deref().unwrap(); + let claims = TokenValidator::new( + fixture.gateway.jwks(), + backend_validation_options(GATEWAY_ISSUER, "billing-service"), + ) + .validate(bearer.strip_prefix("Bearer ").unwrap()) + .unwrap(); + assert_eq!(claims.permissions, vec!["billing:read"]); +} + +#[tokio::test] +async fn failure_modes_map_to_correct_statuses() { + let fixture = proxy_fixture(); + + // Unmatched route: 404, nothing proxied, even with a valid session. + fixture + .server + .get("/unknown") + .authorization_bearer(&fixture.session_token) + .await + .assert_status(StatusCode::NOT_FOUND); + + // No session: 401. + fixture + .server + .get("/invoices") + .await + .assert_status(StatusCode::UNAUTHORIZED); + + // Garbage session: 401. + fixture + .server + .get("/invoices") + .authorization_bearer("garbage") + .await + .assert_status(StatusCode::UNAUTHORIZED); + + // Valid session but no permissions for the route audience: 403. + fixture + .server + .get("/admin/users") + .authorization_bearer(&fixture.session_token) + .await + .assert_status(StatusCode::FORBIDDEN); + + // Connection upgrades fail closed: 501. + fixture + .server + .get("/invoices") + .authorization_bearer(&fixture.session_token) + .add_header("upgrade", "websocket") + .await + .assert_status(StatusCode::NOT_IMPLEMENTED); + + // Authenticated-only route with no audience permissions: proxied. + fixture + .server + .get("/health") + .authorization_bearer(&fixture.session_token) + .await + .assert_status(StatusCode::CREATED); + + assert_eq!( + fixture.upstream.captured.lock().await.len(), + 1, + "only the authenticated-only request reached the upstream" + ); +} + +#[tokio::test] +async fn key_rotation_keeps_outstanding_derived_tokens_valid() { + let fixture = proxy_fixture(); + fixture + .server + .get("/invoices") + .authorization_bearer(&fixture.session_token) + .await + .assert_status(StatusCode::CREATED); + + let captured = fixture.upstream.captured.lock().await; + let old_token = captured[0] + .authorization + .as_deref() + .unwrap() + .strip_prefix("Bearer ") + .unwrap() + .to_string(); + drop(captured); + + fixture + .gateway + .rotate_key(SigningKey::generate_es256("gw-2")); + let validator = TokenValidator::new( + fixture.gateway.jwks(), + backend_validation_options(GATEWAY_ISSUER, "invoice-service"), + ); + assert!(validator.validate(&old_token).is_ok()); +} diff --git a/crates/authorization/ras-authorization-token/Cargo.toml b/crates/authorization/ras-authorization-token/Cargo.toml new file mode 100644 index 0000000..5f36e19 --- /dev/null +++ b/crates/authorization/ras-authorization-token/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "ras-authorization-token" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +description = "Shared RAS token claims, signing, JWKS, and validation primitives for sessions, internal service tokens, and gateway-derived tokens" +keywords = ["api", "auth", "jwt", "jwks", "tokens"] +categories = ["authentication", "web-programming"] +license = "MIT OR Apache-2.0" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" +readme = "README.md" + +[dependencies] +base64 = { workspace = true } +chrono = { workspace = true } +ed25519-dalek = { workspace = true } +hmac = { workspace = true } +p256 = { workspace = true } +rand_core = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } diff --git a/crates/authorization/ras-authorization-token/README.md b/crates/authorization/ras-authorization-token/README.md new file mode 100644 index 0000000..d94bacb --- /dev/null +++ b/crates/authorization/ras-authorization-token/README.md @@ -0,0 +1,25 @@ +# ras-authorization-token + +Shared RAS token primitives: one claims model for web sessions, internal +service-to-service tokens, and gateway-derived backend tokens, with signing, +key rotation, JWKS, and a strict validation pipeline. + +- Token families carried in `typ`: `ras_web_session` (multi-audience, + permissions grouped per audience), `ras_internal_access` and + `ras_gateway_access` (single-audience). +- Algorithms: ES256 (recommended default), EdDSA, and HS256 for embedded + shared-secret deployments. Validators allow only asymmetric algorithms + unless HS256 is explicitly opted in. `alg: none` and unknown algorithms are + always rejected. +- `KeyRing` keeps retired verification keys through rotations so outstanding + tokens stay valid, supports emergency key removal, and publishes JWKS + (asymmetric keys only — HMAC secrets never leave the process). +- `TokenValidator` checks algorithm allowlist, `kid` resolution, key/header + algorithm agreement (key-type-confusion guard), signature, token type, + issuer, audience policy, expiry/not-before with clock skew, and per-family + claim invariants. + +This crate is deliberately HTTP-free; serving and fetching JWKS endpoints is +the job of the authorization control plane and gateway crates built on top. + +See the crate documentation for usage examples. diff --git a/crates/authorization/ras-authorization-token/src/claims.rs b/crates/authorization/ras-authorization-token/src/claims.rs new file mode 100644 index 0000000..c1bd24e --- /dev/null +++ b/crates/authorization/ras-authorization-token/src/claims.rs @@ -0,0 +1,446 @@ +//! The shared RAS claims model. +//! +//! Every token issued inside a RAS deployment — browser web sessions, internal +//! service-to-service access tokens, and gateway-derived backend tokens — uses +//! the same [`RasClaims`] structure, distinguished by [`TokenType`]. Keeping a +//! single claims shape is what lets the auth gateway narrow a web session into +//! a backend token without inventing a second convention. + +use std::collections::BTreeMap; + +use chrono::{DateTime, Duration, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// The token family, carried in the `typ` claim. +/// +/// Validators must always pin the expected token type: a web session must +/// never be accepted where an internal service token is required, and vice +/// versa. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum TokenType { + /// Browser-facing web session. Multi-audience: permissions are grouped + /// per target audience in `audience_permissions`, and `aud` is absent. + #[serde(rename = "ras_web_session")] + WebSession, + /// Internal service-to-service access token issued by the RAS authority. + /// Single-audience: `aud` names the target service. + #[serde(rename = "ras_internal_access")] + InternalService, + /// Backend token derived by the auth gateway from a validated web + /// session. Single-audience, containing only that audience's permissions. + #[serde(rename = "ras_gateway_access")] + GatewayAccess, +} + +/// The kind of principal a token's `sub` identifies. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PrincipalKind { + /// A human user authenticated through an identity provider. + User, + /// A registered internal service acting as itself. + Service, + /// A non-human service account with explicit grants. + ServiceAccount, + /// A registered application principal. + Application, +} + +/// Shared claims for all RAS token families. +/// +/// Construct via [`RasClaims::web_session`], [`RasClaims::internal_service`], +/// or [`RasClaims::gateway_access`] so per-type invariants hold; signing and +/// validation both enforce [`RasClaims::validate_shape`]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RasClaims { + /// Issuer: the RAS authority (or gateway acting as delegated authority). + pub iss: String, + /// Subject: user id, service id, service-account id, or application id. + pub sub: String, + /// Token family. Serialized as the `typ` claim. + #[serde(rename = "typ")] + pub token_type: TokenType, + /// What kind of principal `sub` identifies. + pub principal_kind: PrincipalKind, + /// Issued-at, seconds since epoch. + pub iat: i64, + /// Not-before, seconds since epoch. Optional. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nbf: Option, + /// Expiry, seconds since epoch. + pub exp: i64, + /// Unique token id. + pub jti: String, + /// Target audience for single-audience tokens + /// ([`TokenType::InternalService`], [`TokenType::GatewayAccess`]). + /// Must be absent on web sessions. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub aud: Option, + /// Permissions for the single target audience. Empty for web sessions. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub permissions: Vec, + /// Permissions grouped by audience, for web sessions only. A backend + /// service must never be required to parse permissions for audiences + /// other than its own; single-audience tokens therefore never carry this. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub audience_permissions: Option>>, + /// Authorization snapshot version at issuance time. Lets caches and + /// derived tokens detect stale authorization state. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub authz_version: Option, + /// Free-form additional claims (display name, provider id, ...). + /// Never authoritative for authorization decisions. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option, +} + +impl RasClaims { + /// Build claims for a multi-audience browser web session. + pub fn web_session( + issuer: impl Into, + user_id: impl Into, + audience_permissions: BTreeMap>, + ttl: Duration, + ) -> Self { + let now = Utc::now(); + Self { + iss: issuer.into(), + sub: user_id.into(), + token_type: TokenType::WebSession, + principal_kind: PrincipalKind::User, + iat: now.timestamp(), + nbf: None, + exp: (now + ttl).timestamp(), + jti: Uuid::new_v4().to_string(), + aud: None, + permissions: Vec::new(), + audience_permissions: Some(audience_permissions), + authz_version: None, + metadata: None, + } + } + + /// Build claims for an internal service-to-service access token. + pub fn internal_service( + issuer: impl Into, + subject: impl Into, + principal_kind: PrincipalKind, + audience: impl Into, + permissions: Vec, + ttl: Duration, + ) -> Self { + let now = Utc::now(); + Self { + iss: issuer.into(), + sub: subject.into(), + token_type: TokenType::InternalService, + principal_kind, + iat: now.timestamp(), + nbf: None, + exp: (now + ttl).timestamp(), + jti: Uuid::new_v4().to_string(), + aud: Some(audience.into()), + permissions, + audience_permissions: None, + authz_version: None, + metadata: None, + } + } + + /// Build claims for a gateway-derived single-audience backend token. + /// + /// The gateway must only call this with permissions extracted from a + /// validated web session for exactly `audience` — never invented or + /// widened. + pub fn gateway_access( + issuer: impl Into, + user_id: impl Into, + audience: impl Into, + permissions: Vec, + authz_version: Option, + ttl: Duration, + ) -> Self { + let now = Utc::now(); + Self { + iss: issuer.into(), + sub: user_id.into(), + token_type: TokenType::GatewayAccess, + principal_kind: PrincipalKind::User, + iat: now.timestamp(), + nbf: None, + exp: (now + ttl).timestamp(), + jti: Uuid::new_v4().to_string(), + aud: Some(audience.into()), + permissions, + audience_permissions: None, + authz_version, + metadata: None, + } + } + + /// Set the authorization snapshot version. + pub fn with_authz_version(mut self, version: u64) -> Self { + self.authz_version = Some(version); + self + } + + /// Attach free-form metadata claims. + pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self { + self.metadata = Some(metadata); + self + } + + /// Expiry as a [`DateTime`]. + pub fn expires_at(&self) -> Option> { + DateTime::from_timestamp(self.exp, 0) + } + + /// Check the structural invariants for this token's type. + /// + /// Enforced on both the signing and validation paths, so a malformed or + /// hostile token cannot smuggle multi-audience permissions into a + /// single-audience context or vice versa. + pub fn validate_shape(&self) -> Result<(), String> { + if self.iss.is_empty() { + return Err("iss must not be empty".to_string()); + } + if self.sub.is_empty() { + return Err("sub must not be empty".to_string()); + } + if self.exp <= self.iat { + return Err("exp must be after iat".to_string()); + } + match self.token_type { + TokenType::WebSession => { + if self.aud.is_some() { + return Err("web session tokens must not carry aud".to_string()); + } + if !self.permissions.is_empty() { + return Err( + "web session tokens carry permissions in audience_permissions only" + .to_string(), + ); + } + if self.audience_permissions.is_none() { + return Err("web session tokens require audience_permissions".to_string()); + } + } + TokenType::InternalService | TokenType::GatewayAccess => { + match self.aud.as_deref() { + None | Some("") => { + return Err("single-audience tokens require a non-empty aud".to_string()); + } + Some(_) => {} + } + if self.audience_permissions.is_some() { + return Err( + "single-audience tokens must not carry audience_permissions".to_string() + ); + } + } + } + Ok(()) + } + + /// Permissions this token grants for `audience`, or `None` if the token + /// does not cover that audience at all. + /// + /// For web sessions this looks up the audience group; for single-audience + /// tokens it returns the permission list only when `aud` matches exactly. + pub fn permissions_for_audience(&self, audience: &str) -> Option<&[String]> { + match self.token_type { + TokenType::WebSession => self + .audience_permissions + .as_ref() + .and_then(|map| map.get(audience)) + .map(Vec::as_slice), + TokenType::InternalService | TokenType::GatewayAccess => { + if self.aud.as_deref() == Some(audience) { + Some(&self.permissions) + } else { + None + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn audience_map(audience: &str, permissions: &[&str]) -> BTreeMap> { + BTreeMap::from([( + audience.to_string(), + permissions.iter().map(|p| p.to_string()).collect(), + )]) + } + + #[test] + fn web_session_shape_is_valid() { + let claims = RasClaims::web_session( + "https://auth.internal", + "alice", + audience_map("invoice-service", &["invoice:read"]), + Duration::minutes(30), + ); + assert!(claims.validate_shape().is_ok()); + assert_eq!(claims.token_type, TokenType::WebSession); + assert_eq!(claims.principal_kind, PrincipalKind::User); + assert!(claims.aud.is_none()); + } + + #[test] + fn internal_service_shape_is_valid() { + let claims = RasClaims::internal_service( + "https://auth.internal", + "billing-service", + PrincipalKind::Service, + "invoice-service", + vec!["invoice:write".to_string()], + Duration::minutes(5), + ); + assert!(claims.validate_shape().is_ok()); + assert_eq!(claims.aud.as_deref(), Some("invoice-service")); + } + + #[test] + fn web_session_with_aud_is_rejected() { + let mut claims = RasClaims::web_session( + "https://auth.internal", + "alice", + BTreeMap::new(), + Duration::minutes(30), + ); + claims.aud = Some("invoice-service".to_string()); + assert!(claims.validate_shape().is_err()); + } + + #[test] + fn web_session_with_flat_permissions_is_rejected() { + let mut claims = RasClaims::web_session( + "https://auth.internal", + "alice", + BTreeMap::new(), + Duration::minutes(30), + ); + claims.permissions = vec!["invoice:read".to_string()]; + assert!(claims.validate_shape().is_err()); + } + + #[test] + fn single_audience_token_without_aud_is_rejected() { + let mut claims = RasClaims::internal_service( + "https://auth.internal", + "billing-service", + PrincipalKind::Service, + "invoice-service", + vec![], + Duration::minutes(5), + ); + claims.aud = None; + assert!(claims.validate_shape().is_err()); + + let mut empty_aud = RasClaims::gateway_access( + "https://auth.internal", + "alice", + "invoice-service", + vec![], + None, + Duration::minutes(5), + ); + empty_aud.aud = Some(String::new()); + assert!(empty_aud.validate_shape().is_err()); + } + + #[test] + fn single_audience_token_with_audience_permissions_is_rejected() { + let mut claims = RasClaims::gateway_access( + "https://auth.internal", + "alice", + "invoice-service", + vec!["invoice:read".to_string()], + Some(7), + Duration::minutes(5), + ); + claims.audience_permissions = Some(BTreeMap::new()); + assert!(claims.validate_shape().is_err()); + } + + #[test] + fn expiry_must_follow_issuance() { + let mut claims = RasClaims::internal_service( + "https://auth.internal", + "billing-service", + PrincipalKind::Service, + "invoice-service", + vec![], + Duration::minutes(5), + ); + claims.exp = claims.iat; + assert!(claims.validate_shape().is_err()); + } + + #[test] + fn permissions_for_audience_resolves_per_token_type() { + let session = RasClaims::web_session( + "https://auth.internal", + "alice", + audience_map("invoice-service", &["invoice:read"]), + Duration::minutes(30), + ); + assert_eq!( + session.permissions_for_audience("invoice-service"), + Some(&["invoice:read".to_string()][..]) + ); + assert_eq!(session.permissions_for_audience("billing-service"), None); + + let internal = RasClaims::internal_service( + "https://auth.internal", + "billing-service", + PrincipalKind::Service, + "invoice-service", + vec!["invoice:write".to_string()], + Duration::minutes(5), + ); + assert_eq!( + internal.permissions_for_audience("invoice-service"), + Some(&["invoice:write".to_string()][..]) + ); + assert_eq!(internal.permissions_for_audience("other-service"), None); + } + + #[test] + fn token_type_serializes_to_stable_names() { + assert_eq!( + serde_json::to_value(TokenType::WebSession).unwrap(), + serde_json::json!("ras_web_session") + ); + assert_eq!( + serde_json::to_value(TokenType::InternalService).unwrap(), + serde_json::json!("ras_internal_access") + ); + assert_eq!( + serde_json::to_value(TokenType::GatewayAccess).unwrap(), + serde_json::json!("ras_gateway_access") + ); + } + + #[test] + fn claims_round_trip_through_json() { + let claims = RasClaims::gateway_access( + "https://auth.internal", + "alice", + "invoice-service", + vec!["invoice:read".to_string(), "invoice:approve".to_string()], + Some(42), + Duration::minutes(2), + ); + let json = serde_json::to_value(&claims).unwrap(); + assert_eq!(json["typ"], "ras_gateway_access"); + assert_eq!(json["authz_version"], 42); + let round_trip: RasClaims = serde_json::from_value(json).unwrap(); + assert_eq!(round_trip, claims); + } +} diff --git a/crates/authorization/ras-authorization-token/src/error.rs b/crates/authorization/ras-authorization-token/src/error.rs new file mode 100644 index 0000000..3f3bbff --- /dev/null +++ b/crates/authorization/ras-authorization-token/src/error.rs @@ -0,0 +1,72 @@ +//! Error types for token signing and validation. + +use thiserror::Error; + +use crate::claims::TokenType; + +/// Errors produced while signing, encoding, decoding, or validating RAS tokens. +#[derive(Debug, Error)] +pub enum TokenError { + /// Serialization of the header or claims failed. + #[error("failed to encode token: {0}")] + Encoding(String), + + /// The token is structurally invalid (wrong segment count, bad base64, bad JSON). + #[error("malformed token: {0}")] + Malformed(String), + + /// The signature did not verify against the resolved key. + #[error("token signature is invalid")] + InvalidSignature, + + /// The token header declares an algorithm outside the validator's allowlist. + #[error("token algorithm {0:?} is not allowed")] + DisallowedAlgorithm(String), + + /// The token header carries no `kid`. + #[error("token key id is missing")] + MissingKeyId, + + /// No verification key is known for the token's `kid`. + #[error("no verification key found for key id {kid:?}")] + UnknownKeyId { kid: String }, + + /// The resolved key's algorithm does not match the header algorithm. + /// Guards against key-type confusion attacks. + #[error("key/header algorithm mismatch: key uses {key}, header declares {header}")] + AlgorithmKeyMismatch { key: String, header: String }, + + /// The token's `exp` is in the past (beyond clock skew). + #[error("token has expired")] + Expired, + + /// The token's `nbf` is in the future (beyond clock skew). + #[error("token is not yet valid")] + NotYetValid, + + /// The token's `iss` does not match the expected issuer. + #[error("token issuer mismatch: expected {expected:?}, got {actual:?}")] + IssuerMismatch { expected: String, actual: String }, + + /// The token's `aud` does not satisfy the validator's audience policy. + #[error("token audience mismatch: expected {expected:?}, got {actual:?}")] + AudienceMismatch { + expected: Option, + actual: Option, + }, + + /// The token's `typ` is not one of the expected token types. + #[error("token type mismatch: expected one of {expected:?}, got {actual:?}")] + TokenTypeMismatch { + expected: Vec, + actual: TokenType, + }, + + /// The claims violate the structural invariants of their token type. + #[error("invalid token claims: {0}")] + InvalidClaims(String), + + /// Key material could not be constructed, imported, or exported. + #[error("invalid key material: {0}")] + InvalidKey(String), +} diff --git a/crates/authorization/ras-authorization-token/src/keyring.rs b/crates/authorization/ras-authorization-token/src/keyring.rs new file mode 100644 index 0000000..a0ddfb7 --- /dev/null +++ b/crates/authorization/ras-authorization-token/src/keyring.rs @@ -0,0 +1,228 @@ +//! Token encoding and signing-key rotation. + +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use serde::{Deserialize, Serialize}; + +use crate::claims::RasClaims; +use crate::error::TokenError; +use crate::keys::{JwkSet, SigningKey, VerifyingKey}; + +#[derive(Serialize)] +struct JoseHeader<'a> { + alg: &'a str, + typ: &'static str, + kid: &'a str, +} + +#[derive(Deserialize)] +pub(crate) struct DecodedHeader { + pub alg: String, + #[serde(default)] + pub kid: Option, +} + +/// Sign `claims` with `key`, producing a compact JWS. +/// +/// Claims shape is validated first, so a caller cannot sign a token that +/// violates its token-type invariants. +pub fn sign_claims(key: &SigningKey, claims: &RasClaims) -> Result { + claims.validate_shape().map_err(TokenError::InvalidClaims)?; + + let header = JoseHeader { + alg: key.algorithm().name(), + typ: "JWT", + kid: key.kid(), + }; + let header = serde_json::to_vec(&header) + .map_err(|err| TokenError::Encoding(format!("header serialization failed: {err}")))?; + let payload = serde_json::to_vec(claims) + .map_err(|err| TokenError::Encoding(format!("claims serialization failed: {err}")))?; + + let signing_input = format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(header), + URL_SAFE_NO_PAD.encode(payload) + ); + let signature = key.sign(signing_input.as_bytes())?; + + Ok(format!( + "{signing_input}.{}", + URL_SAFE_NO_PAD.encode(signature) + )) +} + +/// An active signing key plus retired verification keys. +/// +/// Rotation keeps the old key's *verification* half so tokens signed before +/// the rotation stay valid until they expire; the private half is dropped. +/// [`KeyRing::remove_retired`] supports emergency revocation of a retired +/// key, immediately invalidating tokens signed with it. +#[derive(Debug)] +pub struct KeyRing { + active: SigningKey, + retired: Vec, +} + +impl KeyRing { + pub fn new(active: SigningKey) -> Self { + Self { + active, + retired: Vec::new(), + } + } + + pub fn active_kid(&self) -> &str { + self.active.kid() + } + + /// Sign claims with the active key. + pub fn sign(&self, claims: &RasClaims) -> Result { + sign_claims(&self.active, claims) + } + + /// Install a new active key. The previous active key's verification half + /// is retained so outstanding tokens remain valid. + pub fn rotate(&mut self, new_active: SigningKey) { + let old = std::mem::replace(&mut self.active, new_active); + let old_verifier = old.verifying_key(); + self.retired.retain(|key| key.kid() != old_verifier.kid()); + self.retired.push(old_verifier); + } + + /// Emergency-remove a retired verification key. Returns `true` if a key + /// was removed. The active key cannot be removed; rotate first. + pub fn remove_retired(&mut self, kid: &str) -> bool { + let before = self.retired.len(); + self.retired.retain(|key| key.kid() != kid); + self.retired.len() != before + } + + /// All verification keys: active first, then retired. + pub fn verifying_keys(&self) -> Vec { + let mut keys = vec![self.active.verifying_key()]; + keys.extend(self.retired.iter().cloned()); + keys + } + + /// Resolve a verification key by kid. + pub fn resolve(&self, kid: &str) -> Option { + if self.active.kid() == kid { + return Some(self.active.verifying_key()); + } + self.retired.iter().find(|key| key.kid() == kid).cloned() + } + + /// The public JWKS document for this ring. HMAC keys are silently + /// excluded — shared secrets are never published. + pub fn jwks(&self) -> JwkSet { + JwkSet { + keys: self + .verifying_keys() + .iter() + .filter_map(VerifyingKey::to_jwk) + .collect(), + } + } +} + +#[cfg(test)] +mod tests { + use chrono::Duration; + + use super::*; + use crate::claims::{PrincipalKind, RasClaims}; + + fn internal_claims() -> RasClaims { + RasClaims::internal_service( + "https://auth.internal", + "billing-service", + PrincipalKind::Service, + "invoice-service", + vec!["invoice:read".to_string()], + Duration::minutes(5), + ) + } + + #[test] + fn sign_rejects_invalid_claim_shape() { + let key = SigningKey::generate_es256("k1"); + let mut claims = internal_claims(); + claims.aud = None; + assert!(matches!( + sign_claims(&key, &claims), + Err(TokenError::InvalidClaims(_)) + )); + } + + #[test] + fn rotation_retains_old_verification_key() { + let mut ring = KeyRing::new(SigningKey::generate_es256("k1")); + assert_eq!(ring.active_kid(), "k1"); + + ring.rotate(SigningKey::generate_es256("k2")); + assert_eq!(ring.active_kid(), "k2"); + assert!(ring.resolve("k1").is_some()); + assert!(ring.resolve("k2").is_some()); + assert!(ring.resolve("k3").is_none()); + + let jwks = ring.jwks(); + assert_eq!(jwks.keys.len(), 2); + assert_eq!(jwks.keys[0].kid, "k2"); + } + + #[test] + fn emergency_removal_drops_retired_key() { + let mut ring = KeyRing::new(SigningKey::generate_es256("k1")); + ring.rotate(SigningKey::generate_es256("k2")); + + assert!(ring.remove_retired("k1")); + assert!(ring.resolve("k1").is_none()); + assert!(!ring.remove_retired("k1")); + // Active key is not removable. + assert!(!ring.remove_retired("k2")); + assert!(ring.resolve("k2").is_some()); + } + + #[test] + fn rotating_back_to_same_kid_does_not_duplicate_retired_entries() { + let mut ring = KeyRing::new(SigningKey::generate_es256("k1")); + ring.rotate(SigningKey::generate_es256("k2")); + ring.rotate(SigningKey::generate_es256("k1")); + ring.rotate(SigningKey::generate_es256("k2")); + + let kids: Vec<_> = ring + .verifying_keys() + .iter() + .map(|key| key.kid().to_string()) + .collect(); + assert_eq!(kids.iter().filter(|kid| *kid == "k1").count(), 2 - 1); + assert_eq!(kids.iter().filter(|kid| *kid == "k2").count(), 2); + } + + #[test] + fn jwks_excludes_hmac_keys() { + let mut ring = KeyRing::new(SigningKey::from_hmac_secret("hs", vec![1u8; 32]).unwrap()); + assert!(ring.jwks().keys.is_empty()); + + ring.rotate(SigningKey::generate_es256("es")); + let jwks = ring.jwks(); + assert_eq!(jwks.keys.len(), 1); + assert_eq!(jwks.keys[0].kid, "es"); + } + + #[test] + fn header_carries_alg_kid_and_typ() { + let key = SigningKey::generate_ed25519("ed-1"); + let token = sign_claims(&key, &internal_claims()).unwrap(); + let header_segment = token.split('.').next().unwrap(); + let header: serde_json::Value = serde_json::from_slice( + &base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(header_segment) + .unwrap(), + ) + .unwrap(); + assert_eq!(header["alg"], "EdDSA"); + assert_eq!(header["kid"], "ed-1"); + assert_eq!(header["typ"], "JWT"); + } +} diff --git a/crates/authorization/ras-authorization-token/src/keys.rs b/crates/authorization/ras-authorization-token/src/keys.rs new file mode 100644 index 0000000..a1b2345 --- /dev/null +++ b/crates/authorization/ras-authorization-token/src/keys.rs @@ -0,0 +1,539 @@ +//! Signing/verification key material and JWKS types. +//! +//! Supports three algorithms: +//! +//! - `ES256` (ECDSA P-256) — recommended default; widest interop with +//! existing infrastructure (Envoy, Istio, API gateways). +//! - `EdDSA` (Ed25519) — compact modern alternative. +//! - `HS256` (HMAC) — shared-secret mode for embedded single-process +//! deployments only. HMAC keys never appear in JWKS. + +use std::fmt; + +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use hmac::{Hmac, Mac}; +use p256::pkcs8::{DecodePrivateKey as _, EncodePrivateKey as _, LineEnding}; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; + +use crate::error::TokenError; + +/// Supported JWS signing algorithms. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SigningAlgorithm { + /// HMAC-SHA256 shared secret. Embedded/dev mode only. + #[serde(rename = "HS256")] + HS256, + /// ECDSA over P-256 with SHA-256. + #[serde(rename = "ES256")] + ES256, + /// Ed25519. + #[serde(rename = "EdDSA")] + EdDSA, +} + +impl SigningAlgorithm { + /// The JOSE `alg` header value. + pub fn name(&self) -> &'static str { + match self { + Self::HS256 => "HS256", + Self::ES256 => "ES256", + Self::EdDSA => "EdDSA", + } + } + + /// Parse a JOSE `alg` header value. Unknown algorithms (including + /// `none`) return `None` and must be rejected by callers. + pub fn from_name(name: &str) -> Option { + match name { + "HS256" => Some(Self::HS256), + "ES256" => Some(Self::ES256), + "EdDSA" => Some(Self::EdDSA), + _ => None, + } + } +} + +impl fmt::Display for SigningAlgorithm { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.name()) + } +} + +/// Byte secret that redacts itself in `Debug` and never derives serde +/// serialization, so HMAC secrets cannot leak through logs or accidental +/// serialization. +#[derive(Clone)] +pub struct SecretBytes(Vec); + +impl SecretBytes { + pub fn new(bytes: impl Into>) -> Self { + Self(bytes.into()) + } + + /// Access the raw secret. Deliberately verbose so call sites are easy + /// to audit. + pub fn expose_secret(&self) -> &[u8] { + &self.0 + } +} + +impl fmt::Debug for SecretBytes { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("SecretBytes()") + } +} + +enum SigningKeyMaterial { + Hmac(SecretBytes), + Es256(Box), + Ed25519(Box), +} + +/// A private signing key with its key id. +/// +/// `Debug` prints only the kid and algorithm; private material is never +/// formatted. +pub struct SigningKey { + kid: String, + material: SigningKeyMaterial, +} + +impl fmt::Debug for SigningKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SigningKey") + .field("kid", &self.kid) + .field("algorithm", &self.algorithm()) + .finish_non_exhaustive() + } +} + +impl SigningKey { + /// Generate a fresh ES256 (P-256) key. + pub fn generate_es256(kid: impl Into) -> Self { + Self { + kid: kid.into(), + material: SigningKeyMaterial::Es256(Box::new(p256::ecdsa::SigningKey::random( + &mut rand_core::OsRng, + ))), + } + } + + /// Generate a fresh Ed25519 key. + pub fn generate_ed25519(kid: impl Into) -> Self { + Self { + kid: kid.into(), + material: SigningKeyMaterial::Ed25519(Box::new(ed25519_dalek::SigningKey::generate( + &mut rand_core::OsRng, + ))), + } + } + + /// Build an HS256 key from a shared secret (embedded/dev mode only). + /// The secret must be at least 32 bytes. + pub fn from_hmac_secret( + kid: impl Into, + secret: impl Into>, + ) -> Result { + let secret = secret.into(); + if secret.len() < 32 { + return Err(TokenError::InvalidKey( + "HMAC secret must be at least 32 bytes".to_string(), + )); + } + Ok(Self { + kid: kid.into(), + material: SigningKeyMaterial::Hmac(SecretBytes::new(secret)), + }) + } + + /// Import an asymmetric private key from PKCS#8 PEM. + pub fn from_pkcs8_pem( + kid: impl Into, + algorithm: SigningAlgorithm, + pem: &str, + ) -> Result { + let material = match algorithm { + SigningAlgorithm::ES256 => SigningKeyMaterial::Es256(Box::new( + p256::ecdsa::SigningKey::from_pkcs8_pem(pem) + .map_err(|err| TokenError::InvalidKey(format!("invalid ES256 PEM: {err}")))?, + )), + SigningAlgorithm::EdDSA => SigningKeyMaterial::Ed25519(Box::new( + ed25519_dalek::SigningKey::from_pkcs8_pem(pem) + .map_err(|err| TokenError::InvalidKey(format!("invalid Ed25519 PEM: {err}")))?, + )), + SigningAlgorithm::HS256 => { + return Err(TokenError::InvalidKey( + "HMAC keys are raw secrets, not PKCS#8 documents".to_string(), + )); + } + }; + Ok(Self { + kid: kid.into(), + material, + }) + } + + /// Export the private key as PKCS#8 PEM for persistence. + /// + /// The returned string is sensitive material; treat it like any other + /// secret. HMAC keys cannot be exported this way. + pub fn to_pkcs8_pem(&self) -> Result { + match &self.material { + SigningKeyMaterial::Es256(key) => key + .to_pkcs8_pem(LineEnding::LF) + .map(|pem| pem.to_string()) + .map_err(|err| TokenError::InvalidKey(format!("ES256 PEM export failed: {err}"))), + SigningKeyMaterial::Ed25519(key) => key + .to_pkcs8_pem(LineEnding::LF) + .map(|pem| pem.to_string()) + .map_err(|err| TokenError::InvalidKey(format!("Ed25519 PEM export failed: {err}"))), + SigningKeyMaterial::Hmac(_) => Err(TokenError::InvalidKey( + "HMAC keys cannot be exported as PKCS#8".to_string(), + )), + } + } + + pub fn kid(&self) -> &str { + &self.kid + } + + pub fn algorithm(&self) -> SigningAlgorithm { + match &self.material { + SigningKeyMaterial::Hmac(_) => SigningAlgorithm::HS256, + SigningKeyMaterial::Es256(_) => SigningAlgorithm::ES256, + SigningKeyMaterial::Ed25519(_) => SigningAlgorithm::EdDSA, + } + } + + /// The verification counterpart of this key. For HMAC this carries the + /// shared secret (and therefore must stay inside the trust boundary). + pub fn verifying_key(&self) -> VerifyingKey { + let material = match &self.material { + SigningKeyMaterial::Hmac(secret) => VerifyingKeyMaterial::Hmac(secret.clone()), + SigningKeyMaterial::Es256(key) => { + VerifyingKeyMaterial::Es256(p256::ecdsa::VerifyingKey::from(key.as_ref())) + } + SigningKeyMaterial::Ed25519(key) => VerifyingKeyMaterial::Ed25519(key.verifying_key()), + }; + VerifyingKey { + kid: self.kid.clone(), + material, + } + } + + pub(crate) fn sign(&self, message: &[u8]) -> Result, TokenError> { + match &self.material { + SigningKeyMaterial::Hmac(secret) => { + let mut mac = Hmac::::new_from_slice(secret.expose_secret()) + .map_err(|err| TokenError::InvalidKey(format!("invalid HMAC secret: {err}")))?; + mac.update(message); + Ok(mac.finalize().into_bytes().to_vec()) + } + SigningKeyMaterial::Es256(key) => { + use p256::ecdsa::signature::Signer; + let signature: p256::ecdsa::Signature = key.sign(message); + Ok(signature.to_bytes().to_vec()) + } + SigningKeyMaterial::Ed25519(key) => { + use ed25519_dalek::Signer; + Ok(key.sign(message).to_bytes().to_vec()) + } + } + } +} + +#[derive(Clone)] +enum VerifyingKeyMaterial { + Hmac(SecretBytes), + Es256(p256::ecdsa::VerifyingKey), + Ed25519(ed25519_dalek::VerifyingKey), +} + +/// A verification key with its key id. +#[derive(Clone)] +pub struct VerifyingKey { + kid: String, + material: VerifyingKeyMaterial, +} + +impl fmt::Debug for VerifyingKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("VerifyingKey") + .field("kid", &self.kid) + .field("algorithm", &self.algorithm()) + .finish_non_exhaustive() + } +} + +impl VerifyingKey { + pub fn kid(&self) -> &str { + &self.kid + } + + pub fn algorithm(&self) -> SigningAlgorithm { + match &self.material { + VerifyingKeyMaterial::Hmac(_) => SigningAlgorithm::HS256, + VerifyingKeyMaterial::Es256(_) => SigningAlgorithm::ES256, + VerifyingKeyMaterial::Ed25519(_) => SigningAlgorithm::EdDSA, + } + } + + /// Verify `signature` over `message`. All failures collapse to + /// [`TokenError::InvalidSignature`]; callers learn nothing about why. + pub fn verify(&self, message: &[u8], signature: &[u8]) -> Result<(), TokenError> { + match &self.material { + VerifyingKeyMaterial::Hmac(secret) => { + let mut mac = Hmac::::new_from_slice(secret.expose_secret()) + .map_err(|_| TokenError::InvalidSignature)?; + mac.update(message); + mac.verify_slice(signature) + .map_err(|_| TokenError::InvalidSignature) + } + VerifyingKeyMaterial::Es256(key) => { + use p256::ecdsa::signature::Verifier; + let signature = p256::ecdsa::Signature::from_slice(signature) + .map_err(|_| TokenError::InvalidSignature)?; + key.verify(message, &signature) + .map_err(|_| TokenError::InvalidSignature) + } + VerifyingKeyMaterial::Ed25519(key) => { + use ed25519_dalek::Verifier; + let bytes: [u8; 64] = signature + .try_into() + .map_err(|_| TokenError::InvalidSignature)?; + let signature = ed25519_dalek::Signature::from_bytes(&bytes); + key.verify(message, &signature) + .map_err(|_| TokenError::InvalidSignature) + } + } + } + + /// JWK representation. `None` for HMAC keys: shared secrets must never + /// be published through a JWKS document. + pub fn to_jwk(&self) -> Option { + match &self.material { + VerifyingKeyMaterial::Hmac(_) => None, + VerifyingKeyMaterial::Es256(key) => { + let point = key.to_encoded_point(false); + Some(Jwk { + kty: "EC".to_string(), + crv: Some("P-256".to_string()), + x: point.x().map(|x| URL_SAFE_NO_PAD.encode(x)), + y: point.y().map(|y| URL_SAFE_NO_PAD.encode(y)), + kid: self.kid.clone(), + alg: Some(SigningAlgorithm::ES256.name().to_string()), + key_use: Some("sig".to_string()), + }) + } + VerifyingKeyMaterial::Ed25519(key) => Some(Jwk { + kty: "OKP".to_string(), + crv: Some("Ed25519".to_string()), + x: Some(URL_SAFE_NO_PAD.encode(key.to_bytes())), + y: None, + kid: self.kid.clone(), + alg: Some(SigningAlgorithm::EdDSA.name().to_string()), + key_use: Some("sig".to_string()), + }), + } + } + + /// Build a verification key from a JWK entry. + pub fn from_jwk(jwk: &Jwk) -> Result { + let decode = |field: &Option, name: &str| -> Result, TokenError> { + let value = field + .as_deref() + .ok_or_else(|| TokenError::InvalidKey(format!("JWK missing {name}")))?; + URL_SAFE_NO_PAD.decode(value).map_err(|err| { + TokenError::InvalidKey(format!("JWK {name} is not base64url: {err}")) + }) + }; + + let material = match (jwk.kty.as_str(), jwk.crv.as_deref()) { + ("EC", Some("P-256")) => { + let x = decode(&jwk.x, "x")?; + let y = decode(&jwk.y, "y")?; + if x.len() != 32 || y.len() != 32 { + return Err(TokenError::InvalidKey( + "P-256 coordinates must be 32 bytes".to_string(), + )); + } + let mut sec1 = Vec::with_capacity(65); + sec1.push(0x04); + sec1.extend_from_slice(&x); + sec1.extend_from_slice(&y); + VerifyingKeyMaterial::Es256( + p256::ecdsa::VerifyingKey::from_sec1_bytes(&sec1).map_err(|err| { + TokenError::InvalidKey(format!("invalid P-256 point: {err}")) + })?, + ) + } + ("OKP", Some("Ed25519")) => { + let x = decode(&jwk.x, "x")?; + let bytes: [u8; 32] = x.as_slice().try_into().map_err(|_| { + TokenError::InvalidKey("Ed25519 public key must be 32 bytes".to_string()) + })?; + VerifyingKeyMaterial::Ed25519( + ed25519_dalek::VerifyingKey::from_bytes(&bytes).map_err(|err| { + TokenError::InvalidKey(format!("invalid Ed25519 key: {err}")) + })?, + ) + } + (kty, crv) => { + return Err(TokenError::InvalidKey(format!( + "unsupported JWK key type {kty:?} with curve {crv:?}" + ))); + } + }; + + Ok(Self { + kid: jwk.kid.clone(), + material, + }) + } +} + +/// A single JSON Web Key (public verification keys only). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Jwk { + pub kty: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub crv: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub x: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub y: Option, + pub kid: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub alg: Option, + #[serde(rename = "use", default, skip_serializing_if = "Option::is_none")] + pub key_use: Option, +} + +/// A JWKS document as served from an authority's JWKS endpoint. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct JwkSet { + pub keys: Vec, +} + +impl JwkSet { + pub fn find(&self, kid: &str) -> Option<&Jwk> { + self.keys.iter().find(|key| key.kid == kid) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn debug_output_redacts_key_material() { + let secret = SecretBytes::new(vec![7u8; 32]); + assert_eq!(format!("{secret:?}"), "SecretBytes()"); + + let key = SigningKey::generate_es256("k1"); + let debug = format!("{key:?}"); + assert!(debug.contains("k1")); + assert!(debug.contains("ES256")); + assert!(!debug.contains("Es256(")); + + let hmac = SigningKey::from_hmac_secret("k2", vec![7u8; 32]).unwrap(); + let debug = format!("{:?}", hmac.verifying_key()); + assert!(!debug.contains('7')); + } + + #[test] + fn hmac_secret_must_be_long_enough() { + let err = SigningKey::from_hmac_secret("k1", b"short".to_vec()).unwrap_err(); + assert!(matches!(err, TokenError::InvalidKey(_))); + } + + #[test] + fn sign_verify_round_trip_per_algorithm() { + let message = b"signing input"; + for key in [ + SigningKey::generate_es256("es"), + SigningKey::generate_ed25519("ed"), + SigningKey::from_hmac_secret("hs", vec![9u8; 32]).unwrap(), + ] { + let signature = key.sign(message).unwrap(); + let verifier = key.verifying_key(); + verifier.verify(message, &signature).unwrap(); + assert!(verifier.verify(b"other input", &signature).is_err()); + let mut tampered = signature.clone(); + tampered[0] ^= 0xff; + assert!(verifier.verify(message, &tampered).is_err()); + } + } + + #[test] + fn jwk_round_trip_es256_and_ed25519() { + for key in [ + SigningKey::generate_es256("es"), + SigningKey::generate_ed25519("ed"), + ] { + let verifier = key.verifying_key(); + let jwk = verifier.to_jwk().expect("asymmetric keys have JWKs"); + assert_eq!(jwk.kid, key.kid()); + let restored = VerifyingKey::from_jwk(&jwk).unwrap(); + let signature = key.sign(b"msg").unwrap(); + restored.verify(b"msg", &signature).unwrap(); + } + } + + #[test] + fn hmac_keys_are_excluded_from_jwk() { + let key = SigningKey::from_hmac_secret("hs", vec![9u8; 32]).unwrap(); + assert!(key.verifying_key().to_jwk().is_none()); + } + + #[test] + fn jwk_with_unsupported_type_is_rejected() { + let jwk = Jwk { + kty: "RSA".to_string(), + crv: None, + x: None, + y: None, + kid: "k".to_string(), + alg: None, + key_use: None, + }; + assert!(matches!( + VerifyingKey::from_jwk(&jwk), + Err(TokenError::InvalidKey(_)) + )); + } + + #[test] + fn pkcs8_export_import_round_trip() { + for (key, alg) in [ + (SigningKey::generate_es256("es"), SigningAlgorithm::ES256), + (SigningKey::generate_ed25519("ed"), SigningAlgorithm::EdDSA), + ] { + let pem = key.to_pkcs8_pem().unwrap(); + let restored = SigningKey::from_pkcs8_pem(key.kid(), alg, &pem).unwrap(); + let signature = restored.sign(b"msg").unwrap(); + key.verifying_key().verify(b"msg", &signature).unwrap(); + } + } + + #[test] + fn hmac_keys_cannot_use_pkcs8() { + let key = SigningKey::from_hmac_secret("hs", vec![9u8; 32]).unwrap(); + assert!(key.to_pkcs8_pem().is_err()); + assert!(SigningKey::from_pkcs8_pem("hs", SigningAlgorithm::HS256, "ignored").is_err()); + } + + #[test] + fn algorithm_names_round_trip_and_none_is_rejected() { + for alg in [ + SigningAlgorithm::HS256, + SigningAlgorithm::ES256, + SigningAlgorithm::EdDSA, + ] { + assert_eq!(SigningAlgorithm::from_name(alg.name()), Some(alg)); + } + assert_eq!(SigningAlgorithm::from_name("none"), None); + assert_eq!(SigningAlgorithm::from_name("RS256"), None); + } +} diff --git a/crates/authorization/ras-authorization-token/src/lib.rs b/crates/authorization/ras-authorization-token/src/lib.rs new file mode 100644 index 0000000..5bb5d9b --- /dev/null +++ b/crates/authorization/ras-authorization-token/src/lib.rs @@ -0,0 +1,69 @@ +//! Shared RAS token primitives. +//! +//! This crate defines the single token model used across a RAS deployment: +//! +//! - **Claims** ([`RasClaims`]) shared by all token families +//! ([`TokenType::WebSession`], [`TokenType::InternalService`], +//! [`TokenType::GatewayAccess`]), with per-family structural invariants. +//! - **Keys** ([`SigningKey`], [`VerifyingKey`]) supporting ES256 +//! (recommended), EdDSA, and HS256 (embedded/dev shared-secret mode), plus +//! JWKS serialization ([`Jwk`], [`JwkSet`]) for asymmetric keys. +//! - **Signing and rotation** ([`KeyRing`]): an active signing key with +//! retired verification keys, so rotation does not invalidate outstanding +//! tokens, and emergency removal does. +//! - **Validation** ([`TokenValidator`]): algorithm allowlist (asymmetric by +//! default), `kid` resolution through a [`KeyResolver`], key/header +//! algorithm cross-checks, signature verification, issuer/audience/expiry/ +//! not-before/token-type checks with configurable clock skew. +//! +//! Higher layers build on this: the authorization control plane issues +//! [`TokenType::InternalService`] tokens, web sessions carry +//! audience-grouped permissions, and the auth gateway narrows sessions into +//! [`TokenType::GatewayAccess`] tokens — all with the same claims shape and +//! validation pipeline. +//! +//! # Example +//! +//! ``` +//! use chrono::Duration; +//! use ras_authorization_token::{ +//! AudiencePolicy, KeyRing, PrincipalKind, RasClaims, SigningKey, TokenType, +//! TokenValidator, ValidationOptions, +//! }; +//! +//! // Authority side: sign an internal service token. +//! let ring = KeyRing::new(SigningKey::generate_es256("2026-06-key-1")); +//! let claims = RasClaims::internal_service( +//! "https://auth.internal", +//! "billing-service", +//! PrincipalKind::Service, +//! "invoice-service", +//! vec!["invoice:write".to_string()], +//! Duration::minutes(5), +//! ); +//! let token = ring.sign(&claims).unwrap(); +//! +//! // Downstream side: validate via the published JWKS. +//! let validator = TokenValidator::new( +//! ring.jwks(), +//! ValidationOptions::new( +//! "https://auth.internal", +//! AudiencePolicy::Exact("invoice-service".to_string()), +//! vec![TokenType::InternalService], +//! ), +//! ); +//! let validated = validator.validate(&token).unwrap(); +//! assert_eq!(validated.sub, "billing-service"); +//! ``` + +mod claims; +mod error; +mod keyring; +mod keys; +mod validate; + +pub use claims::{PrincipalKind, RasClaims, TokenType}; +pub use error::TokenError; +pub use keyring::{KeyRing, sign_claims}; +pub use keys::{Jwk, JwkSet, SecretBytes, SigningAlgorithm, SigningKey, VerifyingKey}; +pub use validate::{AudiencePolicy, KeyResolver, TokenValidator, ValidationOptions}; diff --git a/crates/authorization/ras-authorization-token/src/validate.rs b/crates/authorization/ras-authorization-token/src/validate.rs new file mode 100644 index 0000000..c176cff --- /dev/null +++ b/crates/authorization/ras-authorization-token/src/validate.rs @@ -0,0 +1,511 @@ +//! Token validation. +//! +//! [`TokenValidator`] performs the full RAS validation pipeline: header +//! parsing, algorithm allowlisting, key resolution by `kid`, key/header +//! algorithm cross-check, signature verification, and claim checks (token +//! type, issuer, expiry/not-before with clock skew, audience policy, and +//! per-type structural invariants). + +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use chrono::{DateTime, Duration, Utc}; + +use crate::claims::{RasClaims, TokenType}; +use crate::error::TokenError; +use crate::keyring::{DecodedHeader, KeyRing}; +use crate::keys::{JwkSet, SigningAlgorithm, VerifyingKey}; + +/// Resolves a verification key for a token's `kid`. +pub trait KeyResolver: Send + Sync { + fn resolve_key(&self, kid: &str) -> Option; +} + +impl KeyResolver for JwkSet { + fn resolve_key(&self, kid: &str) -> Option { + self.find(kid) + .and_then(|jwk| VerifyingKey::from_jwk(jwk).ok()) + } +} + +impl KeyResolver for KeyRing { + fn resolve_key(&self, kid: &str) -> Option { + self.resolve(kid) + } +} + +impl KeyResolver for VerifyingKey { + fn resolve_key(&self, kid: &str) -> Option { + (self.kid() == kid).then(|| self.clone()) + } +} + +impl KeyResolver for std::sync::Arc { + fn resolve_key(&self, kid: &str) -> Option { + (**self).resolve_key(kid) + } +} + +/// How the validator treats the `aud` claim. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AudiencePolicy { + /// `aud` must equal this audience exactly. Use for internal service + /// tokens and gateway-derived tokens. + Exact(String), + /// `aud` must be absent. Use for multi-audience web sessions, whose + /// permissions live in `audience_permissions`. + Absent, +} + +/// Validation policy. Construct with [`ValidationOptions::new`]; the +/// algorithm allowlist defaults to asymmetric algorithms only, so HS256 +/// (shared-secret embedded mode) requires an explicit opt-in via +/// [`ValidationOptions::allow_algorithm`]. +#[derive(Debug, Clone)] +pub struct ValidationOptions { + pub expected_issuer: String, + pub audience: AudiencePolicy, + pub expected_token_types: Vec, + pub allowed_algorithms: Vec, + pub clock_skew: Duration, +} + +impl ValidationOptions { + pub fn new( + expected_issuer: impl Into, + audience: AudiencePolicy, + expected_token_types: impl Into>, + ) -> Self { + Self { + expected_issuer: expected_issuer.into(), + audience, + expected_token_types: expected_token_types.into(), + allowed_algorithms: vec![SigningAlgorithm::ES256, SigningAlgorithm::EdDSA], + clock_skew: Duration::seconds(30), + } + } + + /// Add an algorithm to the allowlist (e.g. HS256 for embedded mode). + pub fn allow_algorithm(mut self, algorithm: SigningAlgorithm) -> Self { + if !self.allowed_algorithms.contains(&algorithm) { + self.allowed_algorithms.push(algorithm); + } + self + } + + /// Override the clock-skew tolerance (default 30 seconds). + pub fn with_clock_skew(mut self, skew: Duration) -> Self { + self.clock_skew = skew; + self + } +} + +/// Validates RAS tokens against a key resolver and a fixed policy. +pub struct TokenValidator { + resolver: R, + options: ValidationOptions, +} + +impl TokenValidator { + pub fn new(resolver: R, options: ValidationOptions) -> Self { + Self { resolver, options } + } + + pub fn options(&self) -> &ValidationOptions { + &self.options + } + + /// Validate `token` against the current wall clock. + pub fn validate(&self, token: &str) -> Result { + self.validate_at(token, Utc::now()) + } + + /// Validate `token` as of `now`. Exposed for deterministic tests. + pub fn validate_at(&self, token: &str, now: DateTime) -> Result { + let mut segments = token.split('.'); + let header_segment = segments + .next() + .ok_or_else(|| TokenError::Malformed("missing header segment".to_string()))?; + let payload_segment = segments + .next() + .ok_or_else(|| TokenError::Malformed("missing payload segment".to_string()))?; + let signature_segment = segments + .next() + .ok_or_else(|| TokenError::Malformed("missing signature segment".to_string()))?; + if segments.next().is_some() { + return Err(TokenError::Malformed("too many segments".to_string())); + } + + let header_bytes = URL_SAFE_NO_PAD + .decode(header_segment) + .map_err(|err| TokenError::Malformed(format!("header is not base64url: {err}")))?; + let header: DecodedHeader = serde_json::from_slice(&header_bytes) + .map_err(|err| TokenError::Malformed(format!("header is not valid JSON: {err}")))?; + + // Unknown algorithms (including "none") fail here, before any + // signature or key handling. + let algorithm = SigningAlgorithm::from_name(&header.alg) + .filter(|alg| self.options.allowed_algorithms.contains(alg)) + .ok_or_else(|| TokenError::DisallowedAlgorithm(header.alg.clone()))?; + + let kid = header.kid.ok_or(TokenError::MissingKeyId)?; + let key = self + .resolver + .resolve_key(&kid) + .ok_or(TokenError::UnknownKeyId { kid })?; + + // The resolved key must itself use the declared algorithm; this is + // the guard against key-type confusion (e.g. an HS256 token naming + // an ES256 key's kid). + if key.algorithm() != algorithm { + return Err(TokenError::AlgorithmKeyMismatch { + key: key.algorithm().name().to_string(), + header: algorithm.name().to_string(), + }); + } + + let signature = URL_SAFE_NO_PAD + .decode(signature_segment) + .map_err(|err| TokenError::Malformed(format!("signature is not base64url: {err}")))?; + let signing_input = format!("{header_segment}.{payload_segment}"); + key.verify(signing_input.as_bytes(), &signature)?; + + let payload = URL_SAFE_NO_PAD + .decode(payload_segment) + .map_err(|err| TokenError::Malformed(format!("payload is not base64url: {err}")))?; + let claims: RasClaims = serde_json::from_slice(&payload) + .map_err(|err| TokenError::Malformed(format!("claims are not valid: {err}")))?; + + if !self + .options + .expected_token_types + .contains(&claims.token_type) + { + return Err(TokenError::TokenTypeMismatch { + expected: self.options.expected_token_types.clone(), + actual: claims.token_type, + }); + } + + if claims.iss != self.options.expected_issuer { + return Err(TokenError::IssuerMismatch { + expected: self.options.expected_issuer.clone(), + actual: claims.iss, + }); + } + + let now_ts = now.timestamp(); + let skew = self.options.clock_skew.num_seconds(); + if now_ts >= claims.exp + skew { + return Err(TokenError::Expired); + } + if let Some(nbf) = claims.nbf + && now_ts + skew < nbf + { + return Err(TokenError::NotYetValid); + } + + match &self.options.audience { + AudiencePolicy::Exact(expected) => { + if claims.aud.as_deref() != Some(expected.as_str()) { + return Err(TokenError::AudienceMismatch { + expected: Some(expected.clone()), + actual: claims.aud, + }); + } + } + AudiencePolicy::Absent => { + if claims.aud.is_some() { + return Err(TokenError::AudienceMismatch { + expected: None, + actual: claims.aud, + }); + } + } + } + + claims.validate_shape().map_err(TokenError::InvalidClaims)?; + + Ok(claims) + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::*; + use crate::claims::PrincipalKind; + use crate::keyring::{KeyRing, sign_claims}; + use crate::keys::SigningKey; + + const ISSUER: &str = "https://auth.internal"; + + fn internal_claims() -> RasClaims { + RasClaims::internal_service( + ISSUER, + "billing-service", + PrincipalKind::Service, + "invoice-service", + vec!["invoice:read".to_string()], + Duration::minutes(5), + ) + } + + fn internal_options() -> ValidationOptions { + ValidationOptions::new( + ISSUER, + AudiencePolicy::Exact("invoice-service".to_string()), + vec![TokenType::InternalService], + ) + } + + fn validator_for(key: &SigningKey, options: ValidationOptions) -> TokenValidator { + TokenValidator::new(key.verifying_key(), options) + } + + #[test] + fn round_trip_es256_eddsa_and_opted_in_hs256() { + for key in [ + SigningKey::generate_es256("k"), + SigningKey::generate_ed25519("k"), + SigningKey::from_hmac_secret("k", vec![3u8; 32]).unwrap(), + ] { + let token = sign_claims(&key, &internal_claims()).unwrap(); + let options = internal_options().allow_algorithm(SigningAlgorithm::HS256); + let claims = validator_for(&key, options).validate(&token).unwrap(); + assert_eq!(claims.sub, "billing-service"); + assert_eq!(claims.permissions, vec!["invoice:read"]); + } + } + + #[test] + fn hs256_is_rejected_without_explicit_opt_in() { + let key = SigningKey::from_hmac_secret("k", vec![3u8; 32]).unwrap(); + let token = sign_claims(&key, &internal_claims()).unwrap(); + let err = validator_for(&key, internal_options()) + .validate(&token) + .unwrap_err(); + assert!(matches!(err, TokenError::DisallowedAlgorithm(alg) if alg == "HS256")); + } + + #[test] + fn alg_none_is_rejected() { + let key = SigningKey::generate_es256("k"); + let token = sign_claims(&key, &internal_claims()).unwrap(); + let payload = token.split('.').nth(1).unwrap(); + let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"none","kid":"k"}"#); + let forged = format!("{header}.{payload}."); + let err = validator_for(&key, internal_options()) + .validate(&forged) + .unwrap_err(); + assert!(matches!(err, TokenError::DisallowedAlgorithm(alg) if alg == "none")); + } + + #[test] + fn key_type_confusion_is_rejected() { + // Sign with HMAC but claim the kid of an ES256 key, with HS256 + // allowed: resolved key algorithm must still match the header. + let es_key = SigningKey::generate_es256("shared-kid"); + let hmac_key = SigningKey::from_hmac_secret("shared-kid", vec![3u8; 32]).unwrap(); + let token = sign_claims(&hmac_key, &internal_claims()).unwrap(); + let options = internal_options().allow_algorithm(SigningAlgorithm::HS256); + let err = validator_for(&es_key, options) + .validate(&token) + .unwrap_err(); + assert!(matches!(err, TokenError::AlgorithmKeyMismatch { .. })); + } + + #[test] + fn missing_and_unknown_kid_are_rejected() { + let key = SigningKey::generate_es256("known"); + let claims = internal_claims(); + + let token = sign_claims(&key, &claims).unwrap(); + let other = SigningKey::generate_es256("other"); + let err = validator_for(&other, internal_options()) + .validate(&token) + .unwrap_err(); + assert!(matches!(err, TokenError::UnknownKeyId { kid } if kid == "known")); + + // Header without kid. + let payload = token.split('.').nth(1).unwrap().to_string(); + let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"ES256"}"#); + let forged = format!("{header}.{payload}.AA"); + let err = validator_for(&key, internal_options()) + .validate(&forged) + .unwrap_err(); + assert!(matches!(err, TokenError::MissingKeyId)); + } + + #[test] + fn tampered_payload_fails_signature_check() { + let key = SigningKey::generate_es256("k"); + let token = sign_claims(&key, &internal_claims()).unwrap(); + let mut parts: Vec<&str> = token.split('.').collect(); + + let mut tampered = internal_claims(); + tampered.permissions = vec!["invoice:admin".to_string()]; + let forged_payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&tampered).unwrap()); + parts[1] = &forged_payload; + let forged = parts.join("."); + + let err = validator_for(&key, internal_options()) + .validate(&forged) + .unwrap_err(); + assert!(matches!(err, TokenError::InvalidSignature)); + } + + #[test] + fn expired_token_is_rejected_with_skew_tolerance() { + let key = SigningKey::generate_es256("k"); + let claims = internal_claims(); + let token = sign_claims(&key, &claims).unwrap(); + let validator = validator_for(&key, internal_options()); + + let exp = DateTime::from_timestamp(claims.exp, 0).unwrap(); + // 10 seconds past expiry but within the 30s skew: still accepted. + assert!( + validator + .validate_at(&token, exp + Duration::seconds(10)) + .is_ok() + ); + // Past expiry plus skew: rejected. + let err = validator + .validate_at(&token, exp + Duration::seconds(31)) + .unwrap_err(); + assert!(matches!(err, TokenError::Expired)); + } + + #[test] + fn not_yet_valid_token_is_rejected() { + let key = SigningKey::generate_es256("k"); + let mut claims = internal_claims(); + claims.nbf = Some(claims.iat + 300); + let token = sign_claims(&key, &claims).unwrap(); + let validator = validator_for(&key, internal_options()); + + let now = DateTime::from_timestamp(claims.iat, 0).unwrap(); + let err = validator.validate_at(&token, now).unwrap_err(); + assert!(matches!(err, TokenError::NotYetValid)); + // Within skew of nbf: accepted. + assert!( + validator + .validate_at(&token, now + Duration::seconds(271)) + .is_ok() + ); + } + + #[test] + fn wrong_issuer_is_rejected() { + let key = SigningKey::generate_es256("k"); + let token = sign_claims(&key, &internal_claims()).unwrap(); + let options = ValidationOptions::new( + "https://other-authority", + AudiencePolicy::Exact("invoice-service".to_string()), + vec![TokenType::InternalService], + ); + let err = validator_for(&key, options).validate(&token).unwrap_err(); + assert!(matches!(err, TokenError::IssuerMismatch { .. })); + } + + #[test] + fn wrong_audience_is_rejected() { + let key = SigningKey::generate_es256("k"); + let token = sign_claims(&key, &internal_claims()).unwrap(); + let options = ValidationOptions::new( + ISSUER, + AudiencePolicy::Exact("billing-service".to_string()), + vec![TokenType::InternalService], + ); + let err = validator_for(&key, options).validate(&token).unwrap_err(); + assert!(matches!(err, TokenError::AudienceMismatch { .. })); + } + + #[test] + fn wrong_token_type_is_rejected() { + let key = SigningKey::generate_es256("k"); + let token = sign_claims(&key, &internal_claims()).unwrap(); + let options = ValidationOptions::new( + ISSUER, + AudiencePolicy::Exact("invoice-service".to_string()), + vec![TokenType::GatewayAccess], + ); + let err = validator_for(&key, options).validate(&token).unwrap_err(); + assert!(matches!( + err, + TokenError::TokenTypeMismatch { + actual: TokenType::InternalService, + .. + } + )); + } + + #[test] + fn web_session_requires_absent_audience_policy() { + let key = SigningKey::generate_es256("k"); + let claims = RasClaims::web_session( + ISSUER, + "alice", + BTreeMap::from([( + "invoice-service".to_string(), + vec!["invoice:read".to_string()], + )]), + Duration::minutes(30), + ); + let token = sign_claims(&key, &claims).unwrap(); + + let options = + ValidationOptions::new(ISSUER, AudiencePolicy::Absent, vec![TokenType::WebSession]); + let validated = validator_for(&key, options).validate(&token).unwrap(); + assert_eq!( + validated.permissions_for_audience("invoice-service"), + Some(&["invoice:read".to_string()][..]) + ); + + // The same web session must not pass an Exact-audience validator. + let options = ValidationOptions::new( + ISSUER, + AudiencePolicy::Exact("invoice-service".to_string()), + vec![TokenType::WebSession], + ); + let err = validator_for(&key, options).validate(&token).unwrap_err(); + assert!(matches!(err, TokenError::AudienceMismatch { .. })); + } + + #[test] + fn validation_works_through_serialized_jwks() { + let key = SigningKey::generate_es256("k1"); + let mut ring = KeyRing::new(key); + let token_old = ring.sign(&internal_claims()).unwrap(); + ring.rotate(SigningKey::generate_ed25519("k2")); + let token_new = ring.sign(&internal_claims()).unwrap(); + + // Serialize the JWKS as a downstream service would fetch it. + let json = serde_json::to_string(&ring.jwks()).unwrap(); + let jwks: JwkSet = serde_json::from_str(&json).unwrap(); + let validator = TokenValidator::new(jwks, internal_options()); + + assert!(validator.validate(&token_old).is_ok()); + assert!(validator.validate(&token_new).is_ok()); + + // Emergency removal: rebuild JWKS without k1 and the old token dies. + ring.remove_retired("k1"); + let validator = TokenValidator::new(ring.jwks(), internal_options()); + assert!(matches!( + validator.validate(&token_old).unwrap_err(), + TokenError::UnknownKeyId { .. } + )); + assert!(validator.validate(&token_new).is_ok()); + } + + #[test] + fn malformed_tokens_are_rejected() { + let key = SigningKey::generate_es256("k"); + let validator = validator_for(&key, internal_options()); + for token in ["", "a.b", "a.b.c.d", "!!!.###.$$$"] { + assert!(matches!( + validator.validate(token).unwrap_err(), + TokenError::Malformed(_) + )); + } + } +} diff --git a/crates/integration/ras-integration-core/Cargo.toml b/crates/integration/ras-integration-core/Cargo.toml new file mode 100644 index 0000000..3ca9948 --- /dev/null +++ b/crates/integration/ras-integration-core/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "ras-integration-core" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +description = "Outbound token framework for RAS services: TokenSource abstraction, token manager with caching, grant stores, and capability-scoped HTTP clients" +keywords = ["api", "auth", "oauth2", "tokens", "integrations"] +categories = ["authentication", "web-programming"] +license = "MIT OR Apache-2.0" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" +readme = "README.md" + +[dependencies] +ras-transport-core = { path = "../../core/ras-transport-core", version = "0.1.0" } + +async-trait = { workspace = true } +chrono = { workspace = true } +http = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +url = { workspace = true } + +[dev-dependencies] +bytes = { workspace = true } +futures = { workspace = true } diff --git a/crates/integration/ras-integration-core/README.md b/crates/integration/ras-integration-core/README.md new file mode 100644 index 0000000..0e7fb8d --- /dev/null +++ b/crates/integration/ras-integration-core/README.md @@ -0,0 +1,27 @@ +# ras-integration-core + +Outbound token framework for RAS services (issue #12): pluggable +`TokenSource`s, a bounds-checked caching `TokenManager`, grant stores, and +capability-scoped `AuthorizedHttpClient`s built on `ras-transport-core`. + +Design rules, all fail-closed: + +- Handlers receive capability-scoped clients (one integration, one subject, + fixed scopes) — never the raw token manager, so user-controlled input can + never select an integration, scope, audience, or subject. +- Integration configs declare allowed scopes, audiences, and outbound base + URLs; requests outside the bounds fail before any token source is + consulted, and bearer tokens are only attached after exact-host validation. +- Cache keys include token family, integration, subject (with principal + mode), audience, canonical scopes, and config version, so token families + and principals cannot collide. +- Concurrent refreshes for the same key are deduplicated; near-expiry leases + refresh early with configurable skew; errors are never cached. +- All secrets live in `SecretString` (redacted `Debug`, no serde). +- No automatic replay of requests after auth failures — retrying + non-idempotent calls is always an explicit caller decision. + +Token sources ship separately: `ras-integration-oauth2` (external +OAuth2/OIDC providers), `ras-integration-ras` (RAS-issued internal service +tokens), and `testing::FakeTokenSource`/`StaticTokenSource` here for tests +and legacy adapters. diff --git a/crates/integration/ras-integration-core/src/client.rs b/crates/integration/ras-integration-core/src/client.rs new file mode 100644 index 0000000..974cc7c --- /dev/null +++ b/crates/integration/ras-integration-core/src/client.rs @@ -0,0 +1,139 @@ +//! Capability-scoped HTTP clients. +//! +//! Handlers receive a preconfigured client for one integration, one subject, +//! and a fixed scope set — not the [`TokenManager`] itself — so handler code +//! cannot request arbitrary integrations, scopes, audiences, or subjects +//! (the confused-deputy guard from issue #12). + +use std::sync::Arc; + +use ras_transport_core::http::Method; +use ras_transport_core::{HttpTransport, TransportRequest, TransportResponse}; +use serde::Serialize; + +use crate::error::IntegrationError; +use crate::manager::TokenManager; +use crate::types::{TokenRequest, TokenSubject}; + +/// An HTTP client bound to one integration, subject, and scope set. +/// +/// Every request is validated against the integration's outbound host +/// allowlist *before* a token is acquired or attached, and the bearer header +/// is set fail-closed. There is no automatic refresh-and-retry of requests: +/// replaying non-idempotent requests after a 401 is the caller's explicit +/// decision, never this client's. +#[derive(Clone)] +pub struct AuthorizedHttpClient { + transport: Arc, + manager: Arc, + integration_id: String, + subject: TokenSubject, + scopes: Vec, + audience: Option, +} + +impl AuthorizedHttpClient { + /// A client acting on behalf of a RAS-authenticated user. + pub fn for_user( + transport: Arc, + manager: Arc, + integration_id: impl Into, + user_id: impl Into, + scopes: impl IntoIterator>, + ) -> Self { + Self { + transport, + manager, + integration_id: integration_id.into(), + subject: TokenSubject::User { + user_id: user_id.into(), + }, + scopes: scopes.into_iter().map(Into::into).collect(), + audience: None, + } + } + + /// A client acting as the calling service itself (service-as-service). + pub fn for_service( + transport: Arc, + manager: Arc, + integration_id: impl Into, + scopes: impl IntoIterator>, + ) -> Self { + Self { + transport, + manager, + integration_id: integration_id.into(), + subject: TokenSubject::Service, + scopes: scopes.into_iter().map(Into::into).collect(), + audience: None, + } + } + + /// A client acting as a service-account principal. + pub fn for_service_account( + transport: Arc, + manager: Arc, + integration_id: impl Into, + service_account_id: impl Into, + scopes: impl IntoIterator>, + ) -> Self { + Self { + transport, + manager, + integration_id: integration_id.into(), + subject: TokenSubject::ServiceAccount { + service_account_id: service_account_id.into(), + }, + scopes: scopes.into_iter().map(Into::into).collect(), + audience: None, + } + } + + /// Pin the target audience (internal service integrations). + pub fn with_audience(mut self, audience: impl Into) -> Self { + self.audience = Some(audience.into()); + self + } + + /// Execute `request` with a managed bearer token attached. + /// + /// Order matters: the URL is checked against the integration's host + /// allowlist first, so no token is even minted for a disallowed target. + pub async fn execute( + &self, + request: TransportRequest, + ) -> Result { + self.manager + .validate_outbound_url(&self.integration_id, &request.url)?; + + let lease = self + .manager + .get_token(TokenRequest { + integration_id: self.integration_id.clone(), + subject: self.subject.clone(), + scopes: self.scopes.clone(), + audience: self.audience.clone(), + force_refresh: false, + }) + .await?; + + let request = request.bearer(lease.access_token.expose_secret())?; + Ok(self.transport.execute(request).await?) + } + + /// `GET` the URL with a managed bearer token. + pub async fn get(&self, url: impl Into) -> Result { + self.execute(TransportRequest::new(Method::GET, url)).await + } + + /// `POST` a JSON body with a managed bearer token. + pub async fn post_json( + &self, + url: impl Into, + body: &T, + ) -> Result { + let request = TransportRequest::new(Method::POST, url).json(body)?; + self.execute(request).await + } +} diff --git a/crates/integration/ras-integration-core/src/config.rs b/crates/integration/ras-integration-core/src/config.rs new file mode 100644 index 0000000..a35edf8 --- /dev/null +++ b/crates/integration/ras-integration-core/src/config.rs @@ -0,0 +1,163 @@ +//! Per-integration configuration: scope/audience bounds and outbound host +//! allowlisting. + +use std::collections::BTreeSet; + +use url::Url; + +use crate::error::IntegrationError; + +/// Declarative bounds for one integration. Token requests and outbound URLs +/// outside these bounds fail closed before any token source is consulted. +#[derive(Debug, Clone)] +pub struct IntegrationConfig { + /// Stable integration id (e.g. `"google-calendar"`, `"invoice-service"`). + pub integration_id: String, + /// Scopes/permissions that may be requested through this integration. + pub allowed_scopes: BTreeSet, + /// Audiences that may be requested (internal service integrations). + pub allowed_audiences: BTreeSet, + /// Base URLs managed bearer tokens may be attached to. + allowed_base_urls: Vec, + /// Bumped whenever the configuration changes; part of every cache key so + /// stale leases die with the config that produced them. + pub config_version: u64, +} + +impl IntegrationConfig { + /// Create a configuration. `allowed_base_urls` must be absolute http(s) + /// URLs; everything else is rejected. + pub fn new( + integration_id: impl Into, + allowed_scopes: impl IntoIterator>, + allowed_base_urls: impl IntoIterator>, + ) -> Result { + let integration_id = integration_id.into(); + let mut parsed = Vec::new(); + for base in allowed_base_urls { + let base = base.as_ref(); + let url = Url::parse(base).map_err(|err| { + IntegrationError::InvalidConfig(format!("invalid allowed base url {base:?}: {err}")) + })?; + if !matches!(url.scheme(), "http" | "https") { + return Err(IntegrationError::InvalidConfig(format!( + "allowed base url {base:?} must be http or https" + ))); + } + if url.host_str().is_none() { + return Err(IntegrationError::InvalidConfig(format!( + "allowed base url {base:?} must have a host" + ))); + } + parsed.push(url); + } + Ok(Self { + integration_id, + allowed_scopes: allowed_scopes.into_iter().map(Into::into).collect(), + allowed_audiences: BTreeSet::new(), + allowed_base_urls: parsed, + config_version: 1, + }) + } + + /// Add allowed audiences (for internal service integrations). + pub fn with_allowed_audiences( + mut self, + audiences: impl IntoIterator>, + ) -> Self { + self.allowed_audiences = audiences.into_iter().map(Into::into).collect(); + self + } + + /// Set the config version (bump on every configuration change). + pub fn with_config_version(mut self, version: u64) -> Self { + self.config_version = version; + self + } + + /// Whether a managed bearer token may be attached to `url`. + /// + /// The URL must parse, and must match an allowed base URL on scheme, + /// exact host, port, and path prefix (segment-aligned, so + /// `/api` does not authorize `/api-private`). Host comparison is exact: + /// `api.example.com.evil.com` never matches `api.example.com`. + pub fn allows_url(&self, url: &str) -> bool { + let Ok(candidate) = Url::parse(url) else { + return false; + }; + self.allowed_base_urls.iter().any(|base| { + if candidate.scheme() != base.scheme() + || candidate.host_str() != base.host_str() + || candidate.port_or_known_default() != base.port_or_known_default() + { + return false; + } + let base_path = base.path().trim_end_matches('/'); + if base_path.is_empty() { + return true; + } + let candidate_path = candidate.path(); + candidate_path == base_path + || candidate_path + .strip_prefix(base_path) + .is_some_and(|rest| rest.starts_with('/')) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config(bases: &[&str]) -> IntegrationConfig { + IntegrationConfig::new("test", ["scope:a"], bases.iter().copied()).unwrap() + } + + #[test] + fn rejects_non_http_and_relative_bases() { + assert!(IntegrationConfig::new("t", ["s"], ["ftp://example.com"]).is_err()); + assert!(IntegrationConfig::new("t", ["s"], ["not a url"]).is_err()); + assert!(IntegrationConfig::new("t", ["s"], ["unix:///tmp/sock"]).is_err()); + } + + #[test] + fn exact_host_matching_defeats_suffix_tricks() { + let config = config(&["https://api.example.com"]); + assert!(config.allows_url("https://api.example.com/v1/items")); + assert!(!config.allows_url("https://api.example.com.evil.com/v1/items")); + assert!(!config.allows_url("https://evil-api.example.com/v1/items")); + assert!(!config.allows_url("https://evil.com/api.example.com")); + } + + #[test] + fn scheme_and_port_must_match() { + let config = config(&["https://api.example.com"]); + assert!(!config.allows_url("http://api.example.com/v1")); + assert!(!config.allows_url("https://api.example.com:8443/v1")); + // Default port is equivalent to explicit 443. + assert!(config.allows_url("https://api.example.com:443/v1")); + } + + #[test] + fn path_prefix_is_segment_aligned() { + let config = config(&["https://api.example.com/api"]); + assert!(config.allows_url("https://api.example.com/api")); + assert!(config.allows_url("https://api.example.com/api/items")); + assert!(!config.allows_url("https://api.example.com/api-private/items")); + assert!(!config.allows_url("https://api.example.com/other")); + } + + #[test] + fn malformed_candidate_urls_fail_closed() { + let config = config(&["https://api.example.com"]); + assert!(!config.allows_url("not a url")); + assert!(!config.allows_url("")); + } + + #[test] + fn internal_http_bases_are_allowed_when_configured() { + let config = config(&["http://invoice-service:3000"]); + assert!(config.allows_url("http://invoice-service:3000/api/invoices")); + assert!(!config.allows_url("http://invoice-service:3001/api/invoices")); + } +} diff --git a/crates/integration/ras-integration-core/src/error.rs b/crates/integration/ras-integration-core/src/error.rs new file mode 100644 index 0000000..6165b9e --- /dev/null +++ b/crates/integration/ras-integration-core/src/error.rs @@ -0,0 +1,75 @@ +//! Error types for the outbound token framework. + +use thiserror::Error; + +/// Errors from token acquisition, configuration validation, and authorized +/// outbound requests. +/// +/// Variants never carry token or secret values. +#[derive(Debug, Error)] +pub enum IntegrationError { + /// No integration is registered under this id. + #[error("unknown integration {integration_id:?}")] + UnknownIntegration { integration_id: String }, + + /// No stored grant exists (or the grant was revoked) for this user and + /// integration; the application must run a consent flow to obtain one. + #[error("consent required for user {user_id:?} on integration {integration_id:?}")] + ConsentRequired { + integration_id: String, + user_id: String, + /// Scopes that were requested but are not covered by any stored + /// grant. Empty when no grant exists at all. + missing_scopes: Vec, + }, + + /// A requested scope is outside the integration's configured + /// `allowed_scopes`. Fails closed before any token source is consulted. + #[error("scope {scope:?} is not allowed for integration {integration_id:?}")] + ScopeNotAllowed { + integration_id: String, + scope: String, + }, + + /// A requested audience is outside the integration's configured + /// `allowed_audiences`. + #[error("audience {audience:?} is not allowed for integration {integration_id:?}")] + AudienceNotAllowed { + integration_id: String, + audience: String, + }, + + /// The outbound URL is not covered by the integration's allowed hosts. + /// Managed bearer tokens are never attached to unvalidated hosts. + #[error("url {url:?} is not an allowed outbound host for integration {integration_id:?}")] + HostNotAllowed { integration_id: String, url: String }, + + /// The token source rejected the request for authorization reasons + /// (e.g. the RAS authority refused issuance). + #[error("token request denied for integration {integration_id:?}: {reason}")] + Denied { + integration_id: String, + reason: String, + }, + + /// The upstream provider failed (network error, 5xx, malformed + /// response). + #[error("provider error for integration {integration_id:?}: {reason}")] + Provider { + integration_id: String, + reason: String, + }, + + /// The grant store failed. Surfaced loudly because losing a rotated + /// refresh token is unrecoverable. + #[error("grant store error: {0}")] + GrantStore(String), + + /// Invalid integration configuration. + #[error("invalid integration configuration: {0}")] + InvalidConfig(String), + + /// Transport-level failure while executing an authorized request. + #[error("transport error: {0}")] + Transport(#[from] ras_transport_core::TransportError), +} diff --git a/crates/integration/ras-integration-core/src/grants.rs b/crates/integration/ras-integration-core/src/grants.rs new file mode 100644 index 0000000..2975914 --- /dev/null +++ b/crates/integration/ras-integration-core/src/grants.rs @@ -0,0 +1,162 @@ +//! Grant storage: refresh tokens and other long-lived user grants. +//! +//! A refresh token is a stored *grant*. RAS never conjures one; the +//! application provides it through a consent flow, admin seeding, or +//! migration, and token sources use it to acquire access tokens. The +//! [`GrantStore`] is a security boundary: implementations hold long-lived +//! credentials and must be treated accordingly. + +use std::collections::HashMap; + +use async_trait::async_trait; +use tokio::sync::RwLock; + +use crate::error::IntegrationError; +use crate::secret::SecretString; + +/// A stored user grant for an external integration. +/// +/// `Debug` redacts the refresh token; the type deliberately does not +/// implement serde traits. +#[derive(Clone)] +pub struct UserGrant { + pub integration_id: String, + pub user_id: String, + pub refresh_token: SecretString, + /// The scopes the user consented to. Token requests are subset-checked + /// against these; broader requests require a new consent flow. + pub scopes: Vec, +} + +impl std::fmt::Debug for UserGrant { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("UserGrant") + .field("integration_id", &self.integration_id) + .field("user_id", &self.user_id) + .field("refresh_token", &self.refresh_token) + .field("scopes", &self.scopes) + .finish() + } +} + +/// Persistence for user grants. Production deployments implement this over +/// their database/secret manager; [`InMemoryGrantStore`] serves tests, dev, +/// and examples. +#[async_trait] +pub trait GrantStore: Send + Sync { + async fn get_user_grant( + &self, + integration_id: &str, + user_id: &str, + ) -> Result, IntegrationError>; + + /// Insert or replace a grant. Token sources call this to persist + /// refresh-token rotation; failures must surface, because losing a + /// rotated refresh token invalidates the stored grant silently. + async fn put_user_grant(&self, grant: UserGrant) -> Result<(), IntegrationError>; + + /// Remove a grant (user disconnect / admin revocation). Returns whether + /// a grant existed. + async fn remove_user_grant( + &self, + integration_id: &str, + user_id: &str, + ) -> Result; +} + +/// In-memory grant store for tests, dev, and examples. +#[derive(Default)] +pub struct InMemoryGrantStore { + grants: RwLock>, +} + +impl InMemoryGrantStore { + pub fn new() -> Self { + Self::default() + } +} + +#[async_trait] +impl GrantStore for InMemoryGrantStore { + async fn get_user_grant( + &self, + integration_id: &str, + user_id: &str, + ) -> Result, IntegrationError> { + let grants = self.grants.read().await; + Ok(grants + .get(&(integration_id.to_string(), user_id.to_string())) + .cloned()) + } + + async fn put_user_grant(&self, grant: UserGrant) -> Result<(), IntegrationError> { + let mut grants = self.grants.write().await; + grants.insert((grant.integration_id.clone(), grant.user_id.clone()), grant); + Ok(()) + } + + async fn remove_user_grant( + &self, + integration_id: &str, + user_id: &str, + ) -> Result { + let mut grants = self.grants.write().await; + Ok(grants + .remove(&(integration_id.to_string(), user_id.to_string())) + .is_some()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn grant(user: &str) -> UserGrant { + UserGrant { + integration_id: "google-calendar".to_string(), + user_id: user.to_string(), + refresh_token: SecretString::new("refresh-secret"), + scopes: vec!["calendar.readonly".to_string()], + } + } + + #[tokio::test] + async fn put_get_remove_round_trip() { + let store = InMemoryGrantStore::new(); + assert!( + store + .get_user_grant("google-calendar", "alice") + .await + .unwrap() + .is_none() + ); + + store.put_user_grant(grant("alice")).await.unwrap(); + let stored = store + .get_user_grant("google-calendar", "alice") + .await + .unwrap() + .expect("grant stored"); + assert_eq!(stored.scopes, vec!["calendar.readonly"]); + + assert!( + store + .remove_user_grant("google-calendar", "alice") + .await + .unwrap() + ); + assert!( + !store + .remove_user_grant("google-calendar", "alice") + .await + .unwrap() + ); + } + + #[test] + fn grant_debug_redacts_refresh_token() { + let debug = format!("{:?}", grant("alice")); + assert!(!debug.contains("refresh-secret")); + assert!(debug.contains("")); + } +} diff --git a/crates/integration/ras-integration-core/src/lib.rs b/crates/integration/ras-integration-core/src/lib.rs new file mode 100644 index 0000000..fe47350 --- /dev/null +++ b/crates/integration/ras-integration-core/src/lib.rs @@ -0,0 +1,81 @@ +//! Outbound token framework for RAS services (issue #12). +//! +//! RAS services frequently call other systems: third-party APIs through +//! OAuth2 grants, and other internal RAS services through RAS-issued tokens. +//! This crate provides the reusable, fail-closed core: +//! +//! - [`TokenSource`] — pluggable token acquisition (OAuth2 in +//! `ras-integration-oauth2`, the RAS internal issuer in +//! `ras-integration-ras`, [`StaticTokenSource`] for legacy/API-key cases, +//! [`testing::FakeTokenSource`] for tests). +//! - [`TokenManager`] — bounds-checked, cached, deduplicated acquisition. +//! Cache keys include token family, integration, subject (with principal +//! mode), audience, canonical scopes, and config version, so token +//! families and principals can never collide. +//! - [`IntegrationConfig`] — declared allowed scopes, audiences, and +//! outbound base URLs per integration; anything outside fails closed. +//! - [`AuthorizedHttpClient`] — the capability-scoped client handlers should +//! receive. Bound to one integration/subject/scope set; validates the +//! outbound host *before* minting a token; never auto-replays requests +//! after auth failures. +//! - [`GrantStore`]/[`UserGrant`] — refresh-token grant persistence with an +//! in-memory implementation for tests and dev. +//! - [`SecretString`] — redacted secret wrapper used for all token and grant +//! material; no serde, no `Debug` leakage. +//! +//! # Example +//! +//! ``` +//! use std::sync::Arc; +//! use ras_integration_core::{ +//! IntegrationConfig, StaticTokenSource, TokenManager, TokenRequest, TokenSubject, +//! }; +//! +//! # tokio::runtime::Runtime::new().unwrap().block_on(async { +//! let manager = Arc::new( +//! TokenManager::builder() +//! .register( +//! IntegrationConfig::new( +//! "metrics-push", +//! ["metrics:write"], +//! ["https://metrics.internal"], +//! ) +//! .unwrap(), +//! Arc::new(StaticTokenSource::new("static-api-key")), +//! ) +//! .unwrap() +//! .build(), +//! ); +//! +//! let lease = manager +//! .get_token(TokenRequest { +//! integration_id: "metrics-push".to_string(), +//! subject: TokenSubject::Service, +//! scopes: vec!["metrics:write".to_string()], +//! audience: None, +//! force_refresh: false, +//! }) +//! .await +//! .unwrap(); +//! assert_eq!(lease.access_token.expose_secret(), "static-api-key"); +//! # }); +//! ``` + +mod client; +mod config; +mod error; +mod grants; +mod manager; +mod secret; +pub mod testing; +mod types; + +pub use client::AuthorizedHttpClient; +pub use config::IntegrationConfig; +pub use error::IntegrationError; +pub use grants::{GrantStore, InMemoryGrantStore, UserGrant}; +pub use manager::{TokenManager, TokenManagerBuilder}; +pub use secret::SecretString; +pub use types::{ + StaticTokenSource, TokenFamily, TokenLease, TokenRequest, TokenSource, TokenSubject, +}; diff --git a/crates/integration/ras-integration-core/src/manager.rs b/crates/integration/ras-integration-core/src/manager.rs new file mode 100644 index 0000000..df9a2f0 --- /dev/null +++ b/crates/integration/ras-integration-core/src/manager.rs @@ -0,0 +1,231 @@ +//! The token manager: bounds-checked, cached, deduplicated token acquisition. + +use std::collections::HashMap; +use std::sync::Arc; + +use chrono::{Duration, Utc}; +use tokio::sync::Mutex; + +use crate::config::IntegrationConfig; +use crate::error::IntegrationError; +use crate::types::{TokenFamily, TokenLease, TokenRequest, TokenSource, TokenSubject}; + +/// Cache key for a leased token. +/// +/// Includes the token family, integration, subject (with principal mode), +/// audience, canonicalized scopes, and config version — so external OAuth +/// tokens, internal RAS tokens, different principals, and different +/// configurations can never collide. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct CacheKey { + family: TokenFamily, + integration_id: String, + subject: String, + audience: Option, + scopes: Vec, + config_version: u64, +} + +struct RegisteredIntegration { + config: IntegrationConfig, + source: Arc, +} + +/// Builder for [`TokenManager`]. +#[derive(Default)] +pub struct TokenManagerBuilder { + integrations: HashMap, + refresh_skew: Option, +} + +impl TokenManagerBuilder { + /// Register an integration with its token source. Duplicate ids are + /// rejected. + pub fn register( + mut self, + config: IntegrationConfig, + source: Arc, + ) -> Result { + let id = config.integration_id.clone(); + if self.integrations.contains_key(&id) { + return Err(IntegrationError::InvalidConfig(format!( + "integration {id:?} registered twice" + ))); + } + self.integrations + .insert(id, RegisteredIntegration { config, source }); + Ok(self) + } + + /// How long before expiry a cached lease is considered stale and + /// refreshed (default 60 seconds). + pub fn refresh_skew(mut self, skew: Duration) -> Self { + self.refresh_skew = Some(skew); + self + } + + pub fn build(self) -> TokenManager { + TokenManager { + integrations: self.integrations, + cache: Mutex::new(HashMap::new()), + inflight: Mutex::new(HashMap::new()), + refresh_skew: self.refresh_skew.unwrap_or_else(|| Duration::seconds(60)), + } + } +} + +/// Acquires, caches, and refreshes tokens through registered +/// [`TokenSource`]s, enforcing each integration's configured bounds. +/// +/// Handler code should not use this directly; inject capability-scoped +/// [`crate::AuthorizedHttpClient`]s instead, so handlers cannot request +/// arbitrary integrations, scopes, audiences, or subjects. +pub struct TokenManager { + integrations: HashMap, + cache: Mutex>, + inflight: Mutex>>>, + refresh_skew: Duration, +} + +impl TokenManager { + pub fn builder() -> TokenManagerBuilder { + TokenManagerBuilder::default() + } + + /// The configuration for an integration, if registered. + pub fn config(&self, integration_id: &str) -> Option<&IntegrationConfig> { + self.integrations + .get(integration_id) + .map(|integration| &integration.config) + } + + /// Check that `url` is an allowed outbound target for the integration. + /// Managed bearer tokens must only be attached after this passes. + pub fn validate_outbound_url( + &self, + integration_id: &str, + url: &str, + ) -> Result<(), IntegrationError> { + let integration = self.integrations.get(integration_id).ok_or_else(|| { + IntegrationError::UnknownIntegration { + integration_id: integration_id.to_string(), + } + })?; + if integration.config.allows_url(url) { + Ok(()) + } else { + Err(IntegrationError::HostNotAllowed { + integration_id: integration_id.to_string(), + url: url.to_string(), + }) + } + } + + /// Acquire a token for `request`, serving from cache when fresh. + /// + /// Scope and audience bounds are enforced before any source call. + /// Concurrent requests for the same cache key are deduplicated: one + /// caller refreshes, the rest wait and reuse the result. + pub async fn get_token(&self, request: TokenRequest) -> Result { + let integration = self + .integrations + .get(&request.integration_id) + .ok_or_else(|| IntegrationError::UnknownIntegration { + integration_id: request.integration_id.clone(), + })?; + + for scope in &request.scopes { + if !integration.config.allowed_scopes.contains(scope) { + return Err(IntegrationError::ScopeNotAllowed { + integration_id: request.integration_id.clone(), + scope: scope.clone(), + }); + } + } + if let Some(audience) = &request.audience + && !integration.config.allowed_audiences.contains(audience) + { + return Err(IntegrationError::AudienceNotAllowed { + integration_id: request.integration_id.clone(), + audience: audience.clone(), + }); + } + + let mut scopes = request.scopes.clone(); + scopes.sort(); + scopes.dedup(); + + let key = CacheKey { + family: integration.source.family(), + integration_id: request.integration_id.clone(), + subject: request.subject.cache_component(), + audience: request.audience.clone(), + scopes: scopes.clone(), + config_version: integration.config.config_version, + }; + + if !request.force_refresh + && let Some(lease) = self.cached_if_fresh(&key).await + { + return Ok(lease); + } + + // Per-key refresh lock: dedups concurrent refreshes for the same key + // without serializing unrelated keys. + let lock = { + let mut inflight = self.inflight.lock().await; + inflight.entry(key.clone()).or_default().clone() + }; + let _guard = lock.lock().await; + + // A concurrent caller may have refreshed while we waited. + if !request.force_refresh + && let Some(lease) = self.cached_if_fresh(&key).await + { + self.inflight.lock().await.remove(&key); + return Ok(lease); + } + + let normalized = TokenRequest { + scopes, + ..request.clone() + }; + let result = integration.source.issue_token(&normalized).await; + + if let Ok(lease) = &result { + let now = Utc::now(); + let mut cache = self.cache.lock().await; + // Opportunistically drop dead leases so the cache stays bounded + // by live keys. + cache.retain(|_, cached| cached.expires_at.is_none_or(|expires_at| expires_at > now)); + cache.insert(key.clone(), lease.clone()); + } + self.inflight.lock().await.remove(&key); + result + } + + /// Drop all cached leases for a subject on an integration. Call after + /// revoking a grant so future requests re-consult the source. + pub async fn invalidate(&self, integration_id: &str, subject: &TokenSubject) { + let component = subject.cache_component(); + let mut cache = self.cache.lock().await; + cache.retain(|key, _| !(key.integration_id == integration_id && key.subject == component)); + } + + async fn cached_if_fresh(&self, key: &CacheKey) -> Option { + let now = Utc::now(); + let mut cache = self.cache.lock().await; + let fresh = match cache.get(key) { + Some(lease) => lease + .expires_at + .is_none_or(|expires_at| expires_at > now + self.refresh_skew), + None => return None, + }; + if fresh { + cache.get(key).cloned() + } else { + cache.remove(key); + None + } + } +} diff --git a/crates/integration/ras-integration-core/src/secret.rs b/crates/integration/ras-integration-core/src/secret.rs new file mode 100644 index 0000000..1516acc --- /dev/null +++ b/crates/integration/ras-integration-core/src/secret.rs @@ -0,0 +1,59 @@ +//! Redacted secret string wrapper. + +use std::fmt; + +/// A string secret (access token, refresh token, client secret) that redacts +/// itself in `Debug`/`Display` and deliberately implements neither +/// `Serialize` nor `Deserialize`, so grants and leases cannot leak secrets +/// through logs, error chains, or accidental serde serialization. +#[derive(Clone)] +pub struct SecretString(String); + +impl SecretString { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + /// Access the raw secret. Deliberately verbose so call sites are easy to + /// audit. + pub fn expose_secret(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for SecretString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("SecretString()") + } +} + +impl fmt::Display for SecretString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("") + } +} + +impl From for SecretString { + fn from(value: String) -> Self { + Self::new(value) + } +} + +impl From<&str> for SecretString { + fn from(value: &str) -> Self { + Self::new(value) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn debug_and_display_redact() { + let secret = SecretString::new("super-secret-token"); + assert_eq!(format!("{secret:?}"), "SecretString()"); + assert_eq!(format!("{secret}"), ""); + assert_eq!(secret.expose_secret(), "super-secret-token"); + } +} diff --git a/crates/integration/ras-integration-core/src/testing.rs b/crates/integration/ras-integration-core/src/testing.rs new file mode 100644 index 0000000..cb149c7 --- /dev/null +++ b/crates/integration/ras-integration-core/src/testing.rs @@ -0,0 +1,92 @@ +//! Test doubles for the token framework. +//! +//! Shipped in the crate proper (not behind `cfg(test)`) so downstream crates +//! and applications can exercise their real service code paths against fakes +//! instead of mocking everything away. + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use async_trait::async_trait; +use tokio::sync::Mutex; + +use crate::error::IntegrationError; +use crate::types::{TokenFamily, TokenLease, TokenRequest, TokenSource}; + +type Responder = + dyn Fn(&TokenRequest) -> Result + Send + Sync + 'static; + +/// A scriptable [`TokenSource`] that records every request it receives. +pub struct FakeTokenSource { + family: TokenFamily, + responder: Box, + delay: Option, + calls: AtomicUsize, + requests: Mutex>, +} + +impl FakeTokenSource { + /// A fake producing responses from `responder`. + pub fn new( + family: TokenFamily, + responder: impl Fn(&TokenRequest) -> Result + + Send + + Sync + + 'static, + ) -> Self { + Self { + family, + responder: Box::new(responder), + delay: None, + calls: AtomicUsize::new(0), + requests: Mutex::new(Vec::new()), + } + } + + /// Sleep before responding — lets tests overlap concurrent requests to + /// exercise refresh deduplication. + pub fn with_delay(mut self, delay: std::time::Duration) -> Self { + self.delay = Some(delay); + self + } + + /// How many times `issue_token` ran. + pub fn call_count(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } + + /// All requests received so far. + pub async fn requests(&self) -> Vec { + self.requests.lock().await.clone() + } +} + +#[async_trait] +impl TokenSource for FakeTokenSource { + fn family(&self) -> TokenFamily { + self.family + } + + async fn issue_token(&self, request: &TokenRequest) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + self.requests.lock().await.push(request.clone()); + if let Some(delay) = self.delay { + tokio::time::sleep(delay).await; + } + (self.responder)(request) + } +} + +/// Convenience constructor: a fake source minting `token-` leases that +/// expire `ttl_secs` from issuance. +pub fn counting_source(family: TokenFamily, ttl_secs: i64) -> Arc { + let counter = AtomicUsize::new(0); + Arc::new(FakeTokenSource::new(family, move |request| { + let n = counter.fetch_add(1, Ordering::SeqCst); + Ok(TokenLease { + access_token: crate::SecretString::new(format!("token-{n}")), + expires_at: Some(chrono::Utc::now() + chrono::Duration::seconds(ttl_secs)), + scopes: request.scopes.clone(), + }) + })) +} diff --git a/crates/integration/ras-integration-core/src/types.rs b/crates/integration/ras-integration-core/src/types.rs new file mode 100644 index 0000000..6e7bff1 --- /dev/null +++ b/crates/integration/ras-integration-core/src/types.rs @@ -0,0 +1,172 @@ +//! Token request/lease types and the `TokenSource` abstraction. + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; + +use crate::error::IntegrationError; +use crate::secret::SecretString; + +/// The principal a token is requested for. +/// +/// The variants correspond to the principal modes from the RAS authorization +/// model: service-as-service, user-delegated, and service-account calls. +/// Cache keys incorporate the full subject so the modes can never collide. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum TokenSubject { + /// The calling service's own identity (service-as-service). + Service, + /// On behalf of a RAS-authenticated user (user grants for external + /// providers; user-delegated calls for internal services). + User { user_id: String }, + /// A non-human service-account principal with explicit grants. + ServiceAccount { service_account_id: String }, +} + +impl TokenSubject { + /// Stable cache-key component, including the principal mode. + pub(crate) fn cache_component(&self) -> String { + match self { + TokenSubject::Service => "service".to_string(), + TokenSubject::User { user_id } => format!("user:{user_id}"), + TokenSubject::ServiceAccount { service_account_id } => { + format!("service_account:{service_account_id}") + } + } + } +} + +/// The family a token source produces. Cache keys include the family so +/// external OAuth tokens, internal RAS JWTs, and static/test tokens can +/// never collide or be attached through the wrong policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TokenFamily { + /// External OAuth2/OIDC provider tokens. + OAuth2, + /// RAS-issued internal service tokens. + RasInternal, + /// Static/legacy tokens (API keys, fixed bearer tokens). + Static, + /// Custom adapter families. + Custom(&'static str), +} + +/// A request for an access token, normally built by [`crate::TokenManager`] +/// from a capability-scoped client rather than constructed in handler code. +#[derive(Debug, Clone)] +pub struct TokenRequest { + /// Which configured integration the token is for. + pub integration_id: String, + /// The principal the token is requested for. + pub subject: TokenSubject, + /// Requested scopes (external providers) or permissions (internal + /// services). + pub scopes: Vec, + /// Target audience for audience-bound token sources (internal services). + pub audience: Option, + /// Bypass the cache and force re-acquisition. + pub force_refresh: bool, +} + +/// A leased access token plus the metadata the manager needs for caching. +/// +/// `Debug` shows everything except the token value. +#[derive(Clone)] +pub struct TokenLease { + /// The bearer token value. + pub access_token: SecretString, + /// When the token expires; `None` means the source provided no expiry + /// (e.g. static tokens) and the lease is cached until invalidated. + pub expires_at: Option>, + /// The scopes actually granted (may exceed the requested set). + pub scopes: Vec, +} + +impl std::fmt::Debug for TokenLease { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TokenLease") + .field("access_token", &self.access_token) + .field("expires_at", &self.expires_at) + .field("scopes", &self.scopes) + .finish() + } +} + +/// A pluggable token acquisition strategy. +/// +/// Implementations: OAuth2/OIDC providers (`ras-integration-oauth2`), the +/// RAS internal issuer (`ras-integration-ras`), static tokens +/// ([`crate::StaticTokenSource`]), and test fakes. +#[async_trait] +pub trait TokenSource: Send + Sync { + /// The token family this source produces. Part of every cache key. + fn family(&self) -> TokenFamily; + + /// Acquire a token for `request`. + /// + /// Sources must fail closed: no grant means + /// [`IntegrationError::ConsentRequired`], denied authorization means + /// [`IntegrationError::Denied`] — never a silent fallback. + async fn issue_token(&self, request: &TokenRequest) -> Result; +} + +/// A trivial source returning a fixed token (API keys, legacy systems, +/// tests). Family [`TokenFamily::Static`]; the lease never expires. +pub struct StaticTokenSource { + token: SecretString, +} + +impl StaticTokenSource { + pub fn new(token: impl Into) -> Self { + Self { + token: token.into(), + } + } +} + +#[async_trait] +impl TokenSource for StaticTokenSource { + fn family(&self) -> TokenFamily { + TokenFamily::Static + } + + async fn issue_token(&self, _request: &TokenRequest) -> Result { + Ok(TokenLease { + access_token: self.token.clone(), + expires_at: None, + scopes: Vec::new(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn subject_cache_components_are_distinct_per_principal_mode() { + let service = TokenSubject::Service.cache_component(); + let user = TokenSubject::User { + user_id: "alice".to_string(), + } + .cache_component(); + let account = TokenSubject::ServiceAccount { + service_account_id: "alice".to_string(), + } + .cache_component(); + assert_ne!(service, user); + assert_ne!(user, account); + assert_ne!(service, account); + } + + #[test] + fn lease_debug_redacts_token() { + let lease = TokenLease { + access_token: SecretString::new("token-value"), + expires_at: None, + scopes: vec!["a".to_string()], + }; + let debug = format!("{lease:?}"); + assert!(!debug.contains("token-value")); + assert!(debug.contains("")); + } +} diff --git a/crates/integration/ras-integration-core/tests/client_tests.rs b/crates/integration/ras-integration-core/tests/client_tests.rs new file mode 100644 index 0000000..8129bd1 --- /dev/null +++ b/crates/integration/ras-integration-core/tests/client_tests.rs @@ -0,0 +1,182 @@ +//! Capability-scoped client behavior: host validation before token minting, +//! fail-closed bearer attachment, no auto-replay. + +use std::sync::Arc; + +use async_trait::async_trait; +use bytes::Bytes; +use ras_integration_core::testing::counting_source; +use ras_integration_core::{ + AuthorizedHttpClient, IntegrationConfig, IntegrationError, TokenFamily, TokenManager, +}; +use ras_transport_core::http::{HeaderMap, StatusCode}; +use ras_transport_core::{ + HttpTransport, TransportError, TransportRequest, TransportResponse, byte_stream_from, +}; +use tokio::sync::Mutex; + +/// Records executed requests and answers 200 OK. +#[derive(Default)] +struct CapturingTransport { + seen: Mutex)>>, +} + +impl CapturingTransport { + async fn seen(&self) -> Vec<(String, Option)> { + self.seen.lock().await.clone() + } +} + +#[async_trait] +impl HttpTransport for CapturingTransport { + async fn execute( + &self, + request: TransportRequest, + ) -> Result { + let auth = request + .headers + .get(ras_transport_core::http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .map(|value| value.to_string()); + self.seen.lock().await.push((request.url.clone(), auth)); + Ok(TransportResponse::new( + StatusCode::OK, + HeaderMap::new(), + byte_stream_from(futures::stream::iter(vec![Ok(Bytes::from_static(b"{}"))])), + )) + } +} + +fn manager_with(source: Arc) -> Arc { + Arc::new( + TokenManager::builder() + .register( + IntegrationConfig::new( + "google-calendar", + ["calendar.readonly"], + ["https://www.googleapis.com/calendar"], + ) + .unwrap(), + source, + ) + .unwrap() + .build(), + ) +} + +#[tokio::test] +async fn bearer_token_is_attached_to_allowed_host() { + let source = counting_source(TokenFamily::OAuth2, 3600); + let transport = Arc::new(CapturingTransport::default()); + let client = AuthorizedHttpClient::for_user( + transport.clone(), + manager_with(source), + "google-calendar", + "alice", + ["calendar.readonly"], + ); + + let response = client + .get("https://www.googleapis.com/calendar/v3/events") + .await + .unwrap(); + assert!(response.is_success()); + + let seen = transport.seen().await; + assert_eq!(seen.len(), 1); + assert_eq!(seen[0].1.as_deref(), Some("Bearer token-0")); +} + +#[tokio::test] +async fn disallowed_host_fails_before_any_token_is_minted() { + let source = counting_source(TokenFamily::OAuth2, 3600); + let transport = Arc::new(CapturingTransport::default()); + let client = AuthorizedHttpClient::for_user( + transport.clone(), + manager_with(source.clone()), + "google-calendar", + "alice", + ["calendar.readonly"], + ); + + let err = client + .get("https://attacker.example.com/calendar/v3/events") + .await + .unwrap_err(); + assert!(matches!(err, IntegrationError::HostNotAllowed { .. })); + assert_eq!(source.call_count(), 0, "no token minted for bad host"); + assert!(transport.seen().await.is_empty(), "nothing sent"); +} + +#[tokio::test] +async fn scopes_outside_client_capability_cannot_be_requested() { + // The client is pinned to its scope set at construction; the manager + // enforces config bounds. A client built with an unallowed scope fails + // closed on every request. + let source = counting_source(TokenFamily::OAuth2, 3600); + let client = AuthorizedHttpClient::for_user( + Arc::new(CapturingTransport::default()), + manager_with(source.clone()), + "google-calendar", + "alice", + ["calendar.write"], + ); + + let err = client + .get("https://www.googleapis.com/calendar/v3/events") + .await + .unwrap_err(); + assert!(matches!(err, IntegrationError::ScopeNotAllowed { .. })); + assert_eq!(source.call_count(), 0); +} + +#[tokio::test] +async fn repeated_calls_reuse_cached_lease() { + let source = counting_source(TokenFamily::OAuth2, 3600); + let transport = Arc::new(CapturingTransport::default()); + let client = AuthorizedHttpClient::for_user( + transport.clone(), + manager_with(source.clone()), + "google-calendar", + "alice", + ["calendar.readonly"], + ); + + for _ in 0..3 { + client + .get("https://www.googleapis.com/calendar/v3/events") + .await + .unwrap(); + } + assert_eq!(source.call_count(), 1); + let seen = transport.seen().await; + assert_eq!(seen.len(), 3); + assert!( + seen.iter() + .all(|(_, auth)| auth.as_deref() == Some("Bearer token-0")) + ); +} + +#[tokio::test] +async fn post_json_sets_body_and_bearer() { + let source = counting_source(TokenFamily::OAuth2, 3600); + let transport = Arc::new(CapturingTransport::default()); + let client = AuthorizedHttpClient::for_user( + transport.clone(), + manager_with(source), + "google-calendar", + "alice", + ["calendar.readonly"], + ); + + client + .post_json( + "https://www.googleapis.com/calendar/v3/events", + &serde_json::json!({"summary": "standup"}), + ) + .await + .unwrap(); + let seen = transport.seen().await; + assert_eq!(seen.len(), 1); + assert!(seen[0].1.as_deref().unwrap().starts_with("Bearer ")); +} diff --git a/crates/integration/ras-integration-core/tests/manager_tests.rs b/crates/integration/ras-integration-core/tests/manager_tests.rs new file mode 100644 index 0000000..1715e59 --- /dev/null +++ b/crates/integration/ras-integration-core/tests/manager_tests.rs @@ -0,0 +1,316 @@ +//! Token manager behavior: bounds, caching, refresh, dedup, invalidation. + +use std::sync::Arc; +use std::time::Duration as StdDuration; + +use chrono::{Duration, Utc}; +use ras_integration_core::TokenFamily; +use ras_integration_core::testing::{FakeTokenSource, counting_source}; +use ras_integration_core::{ + IntegrationConfig, IntegrationError, SecretString, TokenLease, TokenManager, TokenRequest, + TokenSubject, +}; + +fn config(id: &str, scopes: &[&str]) -> IntegrationConfig { + IntegrationConfig::new(id, scopes.iter().copied(), ["https://api.example.com"]).unwrap() +} + +fn request(id: &str, scopes: &[&str]) -> TokenRequest { + TokenRequest { + integration_id: id.to_string(), + subject: TokenSubject::User { + user_id: "alice".to_string(), + }, + scopes: scopes.iter().map(|s| s.to_string()).collect(), + audience: None, + force_refresh: false, + } +} + +#[tokio::test] +async fn unknown_integration_fails_closed() { + let manager = TokenManager::builder().build(); + let err = manager.get_token(request("nope", &[])).await.unwrap_err(); + assert!(matches!( + err, + IntegrationError::UnknownIntegration { integration_id } if integration_id == "nope" + )); +} + +#[tokio::test] +async fn scope_outside_allowlist_fails_before_source() { + let source = counting_source(TokenFamily::OAuth2, 3600); + let manager = TokenManager::builder() + .register(config("cal", &["calendar.readonly"]), source.clone()) + .unwrap() + .build(); + + let err = manager + .get_token(request("cal", &["calendar.write"])) + .await + .unwrap_err(); + assert!( + matches!(err, IntegrationError::ScopeNotAllowed { scope, .. } if scope == "calendar.write") + ); + assert_eq!(source.call_count(), 0, "source must never be consulted"); +} + +#[tokio::test] +async fn audience_outside_allowlist_fails_before_source() { + let source = counting_source(TokenFamily::RasInternal, 300); + let manager = TokenManager::builder() + .register( + config("invoice", &["invoice:read"]).with_allowed_audiences(["invoice-service"]), + source.clone(), + ) + .unwrap() + .build(); + + let mut req = request("invoice", &["invoice:read"]); + req.audience = Some("admin-service".to_string()); + let err = manager.get_token(req).await.unwrap_err(); + assert!(matches!( + err, + IntegrationError::AudienceNotAllowed { audience, .. } if audience == "admin-service" + )); + assert_eq!(source.call_count(), 0); +} + +#[tokio::test] +async fn fresh_lease_is_served_from_cache() { + let source = counting_source(TokenFamily::OAuth2, 3600); + let manager = TokenManager::builder() + .register(config("cal", &["calendar.readonly"]), source.clone()) + .unwrap() + .build(); + + let first = manager + .get_token(request("cal", &["calendar.readonly"])) + .await + .unwrap(); + let second = manager + .get_token(request("cal", &["calendar.readonly"])) + .await + .unwrap(); + assert_eq!( + first.access_token.expose_secret(), + second.access_token.expose_secret() + ); + assert_eq!(source.call_count(), 1); +} + +#[tokio::test] +async fn scope_order_and_duplicates_share_one_cache_entry() { + let source = counting_source(TokenFamily::OAuth2, 3600); + let manager = TokenManager::builder() + .register(config("cal", &["a", "b"]), source.clone()) + .unwrap() + .build(); + + manager + .get_token(request("cal", &["b", "a"])) + .await + .unwrap(); + manager + .get_token(request("cal", &["a", "b", "a"])) + .await + .unwrap(); + assert_eq!(source.call_count(), 1, "canonicalized scopes share a key"); +} + +#[tokio::test] +async fn distinct_subjects_audiences_and_scopes_get_distinct_leases() { + let source = counting_source(TokenFamily::RasInternal, 300); + let manager = TokenManager::builder() + .register( + config("svc", &["a", "b"]).with_allowed_audiences(["aud-1", "aud-2"]), + source.clone(), + ) + .unwrap() + .build(); + + let mut base = request("svc", &["a"]); + base.audience = Some("aud-1".to_string()); + + manager.get_token(base.clone()).await.unwrap(); + + let mut other_subject = base.clone(); + other_subject.subject = TokenSubject::Service; + manager.get_token(other_subject).await.unwrap(); + + let mut other_audience = base.clone(); + other_audience.audience = Some("aud-2".to_string()); + manager.get_token(other_audience).await.unwrap(); + + let mut other_scopes = base.clone(); + other_scopes.scopes = vec!["b".to_string()]; + manager.get_token(other_scopes).await.unwrap(); + + assert_eq!(source.call_count(), 4); +} + +#[tokio::test] +async fn lease_expiring_within_skew_is_refreshed() { + // TTL 30s with the default 60s refresh skew: always considered stale. + let source = counting_source(TokenFamily::OAuth2, 30); + let manager = TokenManager::builder() + .register(config("cal", &["a"]), source.clone()) + .unwrap() + .build(); + + manager.get_token(request("cal", &["a"])).await.unwrap(); + manager.get_token(request("cal", &["a"])).await.unwrap(); + assert_eq!(source.call_count(), 2, "near-expiry leases refresh early"); +} + +#[tokio::test] +async fn force_refresh_bypasses_cache() { + let source = counting_source(TokenFamily::OAuth2, 3600); + let manager = TokenManager::builder() + .register(config("cal", &["a"]), source.clone()) + .unwrap() + .build(); + + manager.get_token(request("cal", &["a"])).await.unwrap(); + let mut forced = request("cal", &["a"]); + forced.force_refresh = true; + manager.get_token(forced).await.unwrap(); + assert_eq!(source.call_count(), 2); +} + +#[tokio::test] +async fn concurrent_requests_for_same_key_are_deduplicated() { + let source = Arc::new( + FakeTokenSource::new(TokenFamily::OAuth2, |req| { + Ok(TokenLease { + access_token: SecretString::new("shared-token"), + expires_at: Some(Utc::now() + Duration::hours(1)), + scopes: req.scopes.clone(), + }) + }) + .with_delay(StdDuration::from_millis(50)), + ); + let manager = Arc::new( + TokenManager::builder() + .register(config("cal", &["a"]), source.clone()) + .unwrap() + .build(), + ); + + let tasks: Vec<_> = (0..16) + .map(|_| { + let manager = manager.clone(); + tokio::spawn(async move { manager.get_token(request("cal", &["a"])).await }) + }) + .collect(); + for task in tasks { + task.await.unwrap().unwrap(); + } + assert_eq!(source.call_count(), 1, "one refresh serves all waiters"); +} + +#[tokio::test] +async fn source_errors_propagate_and_are_not_cached() { + let source = Arc::new(FakeTokenSource::new(TokenFamily::OAuth2, |req| { + Err(IntegrationError::ConsentRequired { + integration_id: req.integration_id.clone(), + user_id: "alice".to_string(), + missing_scopes: req.scopes.clone(), + }) + })); + let manager = TokenManager::builder() + .register(config("cal", &["a"]), source.clone()) + .unwrap() + .build(); + + for _ in 0..2 { + let err = manager.get_token(request("cal", &["a"])).await.unwrap_err(); + assert!(matches!(err, IntegrationError::ConsentRequired { .. })); + } + assert_eq!(source.call_count(), 2, "errors are never cached"); +} + +#[tokio::test] +async fn invalidate_drops_cached_leases_for_subject() { + let source = counting_source(TokenFamily::OAuth2, 3600); + let manager = TokenManager::builder() + .register(config("cal", &["a"]), source.clone()) + .unwrap() + .build(); + + manager.get_token(request("cal", &["a"])).await.unwrap(); + manager + .invalidate( + "cal", + &TokenSubject::User { + user_id: "alice".to_string(), + }, + ) + .await; + manager.get_token(request("cal", &["a"])).await.unwrap(); + assert_eq!(source.call_count(), 2); +} + +#[tokio::test] +async fn config_version_bump_invalidates_cache_keys() { + // Two managers simulate a config rollout; the cache key includes the + // version, so this asserts on key construction, not shared state. + let source = counting_source(TokenFamily::OAuth2, 3600); + let manager = TokenManager::builder() + .register(config("cal", &["a"]).with_config_version(1), source.clone()) + .unwrap() + .build(); + manager.get_token(request("cal", &["a"])).await.unwrap(); + + let manager_v2 = TokenManager::builder() + .register(config("cal", &["a"]).with_config_version(2), source.clone()) + .unwrap() + .build(); + manager_v2.get_token(request("cal", &["a"])).await.unwrap(); + assert_eq!(source.call_count(), 2); +} + +#[tokio::test] +async fn duplicate_registration_is_rejected() { + let result = TokenManager::builder() + .register( + config("cal", &["a"]), + counting_source(TokenFamily::OAuth2, 60), + ) + .unwrap() + .register( + config("cal", &["a"]), + counting_source(TokenFamily::OAuth2, 60), + ); + assert!(matches!( + result, + Err(IntegrationError::InvalidConfig(message)) if message.contains("cal") + )); +} + +#[tokio::test] +async fn outbound_url_validation_fails_closed() { + let manager = TokenManager::builder() + .register( + config("cal", &["a"]), + counting_source(TokenFamily::OAuth2, 60), + ) + .unwrap() + .build(); + + manager + .validate_outbound_url("cal", "https://api.example.com/v1") + .unwrap(); + assert!(matches!( + manager + .validate_outbound_url("cal", "https://api.example.com.evil.com/v1") + .unwrap_err(), + IntegrationError::HostNotAllowed { .. } + )); + assert!(matches!( + manager + .validate_outbound_url("unknown", "https://api.example.com/v1") + .unwrap_err(), + IntegrationError::UnknownIntegration { .. } + )); +} diff --git a/crates/integration/ras-integration-oauth2/Cargo.toml b/crates/integration/ras-integration-oauth2/Cargo.toml new file mode 100644 index 0000000..6e14e40 --- /dev/null +++ b/crates/integration/ras-integration-oauth2/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "ras-integration-oauth2" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +description = "OAuth2/OIDC token source for the RAS outbound token framework: refresh-token and client-credentials flows with rotation persistence and PKCE consent helpers" +keywords = ["api", "auth", "oauth2", "oidc", "tokens"] +categories = ["authentication", "web-programming"] +license = "MIT OR Apache-2.0" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" +readme = "README.md" + +[dependencies] +ras-integration-core = { path = "../ras-integration-core", version = "0.1.0" } +ras-transport-core = { path = "../../core/ras-transport-core", version = "0.1.0" } + +async-trait = { workspace = true } +base64 = { workspace = true } +chrono = { workspace = true } +rand = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde_urlencoded = { workspace = true } +sha2 = { workspace = true } +tokio = { workspace = true } +url = { workspace = true } + +[dev-dependencies] +bytes = { workspace = true } +futures = { workspace = true } +http = { workspace = true } diff --git a/crates/integration/ras-integration-oauth2/README.md b/crates/integration/ras-integration-oauth2/README.md new file mode 100644 index 0000000..2dfee8c --- /dev/null +++ b/crates/integration/ras-integration-oauth2/README.md @@ -0,0 +1,21 @@ +# ras-integration-oauth2 + +OAuth2/OIDC token source for the RAS outbound token framework +(`ras-integration-core`). + +- Refresh-token flow for user subjects backed by stored grants, with scope + subset-checking against the consented scopes and refresh-token rotation + persisted to the `GrantStore` before a lease is returned. +- Client-credentials flow for service subjects, with optional `audience` + forwarding. +- `invalid_grant` responses surface as typed `ConsentRequired` errors so + applications can route users back through consent. +- `ConsentFlow`: PKCE (S256) authorization URLs with opaque, single-use, + expiring `state` bound to the initiating user, integration, redirect URI, + scopes, and verifier; callback validation; authorization-code exchange that + stores the resulting grant. +- Provider endpoints must be https unless explicitly opted into insecure + http for in-process test fakes. + +All HTTP goes through `ras-transport-core`, so the provider can be faked +in-process for tests. diff --git a/crates/integration/ras-integration-oauth2/src/config.rs b/crates/integration/ras-integration-oauth2/src/config.rs new file mode 100644 index 0000000..59dc170 --- /dev/null +++ b/crates/integration/ras-integration-oauth2/src/config.rs @@ -0,0 +1,138 @@ +//! OAuth2 provider configuration. + +use ras_integration_core::{IntegrationError, SecretString}; +use url::Url; + +/// Configuration for one external OAuth2/OIDC provider client. +/// +/// `Debug` redacts the client secret. +#[derive(Clone)] +pub struct OAuth2ProviderConfig { + /// The provider's token endpoint (must be https unless + /// `danger_allow_insecure_http` is set, e.g. for in-process fakes). + pub token_endpoint: String, + /// The provider's authorization endpoint; required for consent flows. + pub authorization_endpoint: Option, + /// OAuth client id registered with the provider. + pub client_id: String, + /// OAuth client secret; required for client-credentials, optional for + /// public PKCE clients. + pub client_secret: Option, + /// Allow plain-http endpoints. For tests and in-process fakes only. + pub danger_allow_insecure_http: bool, +} + +impl std::fmt::Debug for OAuth2ProviderConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OAuth2ProviderConfig") + .field("token_endpoint", &self.token_endpoint) + .field("authorization_endpoint", &self.authorization_endpoint) + .field("client_id", &self.client_id) + .field( + "client_secret", + &self.client_secret.as_ref().map(|_| ""), + ) + .field( + "danger_allow_insecure_http", + &self.danger_allow_insecure_http, + ) + .finish() + } +} + +impl OAuth2ProviderConfig { + pub fn new( + token_endpoint: impl Into, + client_id: impl Into, + ) -> Result { + let config = Self { + token_endpoint: token_endpoint.into(), + authorization_endpoint: None, + client_id: client_id.into(), + client_secret: None, + danger_allow_insecure_http: false, + }; + config.validate()?; + Ok(config) + } + + pub fn with_authorization_endpoint( + mut self, + endpoint: impl Into, + ) -> Result { + self.authorization_endpoint = Some(endpoint.into()); + self.validate()?; + Ok(self) + } + + pub fn with_client_secret(mut self, secret: impl Into) -> Self { + self.client_secret = Some(secret.into()); + self + } + + /// Permit plain-http endpoints (tests/in-process fakes only). + pub fn with_danger_allow_insecure_http(mut self) -> Self { + self.danger_allow_insecure_http = true; + self + } + + pub(crate) fn validate(&self) -> Result<(), IntegrationError> { + for (name, endpoint) in [ + ("token_endpoint", Some(&self.token_endpoint)), + ( + "authorization_endpoint", + self.authorization_endpoint.as_ref(), + ), + ] { + let Some(endpoint) = endpoint else { continue }; + let url = Url::parse(endpoint).map_err(|err| { + IntegrationError::InvalidConfig(format!("invalid {name} {endpoint:?}: {err}")) + })?; + match url.scheme() { + "https" => {} + "http" if self.danger_allow_insecure_http => {} + scheme => { + return Err(IntegrationError::InvalidConfig(format!( + "{name} {endpoint:?} uses scheme {scheme:?}; only https is allowed \ + (or http with danger_allow_insecure_http)" + ))); + } + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn https_endpoints_are_accepted() { + let config = OAuth2ProviderConfig::new("https://provider.test/token", "client").unwrap(); + assert_eq!(config.client_id, "client"); + } + + #[test] + fn http_requires_explicit_danger_flag() { + assert!(OAuth2ProviderConfig::new("http://provider.test/token", "client").is_err()); + + let config = OAuth2ProviderConfig { + token_endpoint: "http://provider.test/token".to_string(), + authorization_endpoint: None, + client_id: "client".to_string(), + client_secret: None, + danger_allow_insecure_http: true, + }; + assert!(config.validate().is_ok()); + } + + #[test] + fn debug_redacts_client_secret() { + let config = OAuth2ProviderConfig::new("https://provider.test/token", "client") + .unwrap() + .with_client_secret("super-secret"); + let debug = format!("{config:?}"); + assert!(!debug.contains("super-secret")); + } +} diff --git a/crates/integration/ras-integration-oauth2/src/consent.rs b/crates/integration/ras-integration-oauth2/src/consent.rs new file mode 100644 index 0000000..6b4d98d --- /dev/null +++ b/crates/integration/ras-integration-oauth2/src/consent.rs @@ -0,0 +1,285 @@ +//! PKCE consent-flow helpers. +//! +//! Applications own their HTTP routes; this module owns the security +//! invariants of the consent round trip: +//! +//! - `state` is opaque, random, single-use, and expiring. +//! - Each pending consent is bound to the initiating RAS user, integration, +//! provider authorization endpoint, redirect URI, requested scopes, and +//! PKCE verifier. The callback must present the same state *and* user. +//! - The PKCE verifier never leaves the process until the code exchange. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use chrono::{DateTime, Duration, Utc}; +use rand::RngCore; +use ras_integration_core::{GrantStore, IntegrationError, SecretString, TokenLease, UserGrant}; +use ras_transport_core::http::Method; +use ras_transport_core::{HttpTransport, TransportRequest}; +use sha2::{Digest, Sha256}; +use url::Url; + +use crate::config::OAuth2ProviderConfig; + +/// A prepared authorization request: send the user to `url`, keep `state` +/// in the redirect round trip. +#[derive(Debug, Clone)] +pub struct AuthorizationRedirect { + pub url: String, + pub state: String, +} + +struct PendingConsent { + integration_id: String, + user_id: String, + redirect_uri: String, + scopes: Vec, + code_verifier: SecretString, + expires_at: DateTime, +} + +/// Tracks pending consent flows and validates callbacks. +pub struct ConsentFlow { + pending: Mutex>, + ttl: Duration, +} + +impl ConsentFlow { + /// `ttl` bounds how long a consent round trip may take (10 minutes is a + /// reasonable default). + pub fn new(ttl: Duration) -> Self { + Self { + pending: Mutex::new(HashMap::new()), + ttl, + } + } + + /// Begin a consent flow for `user_id` on `integration_id`. Returns the + /// provider authorization URL (with PKCE S256 challenge) and the opaque + /// state value. + pub fn begin( + &self, + config: &OAuth2ProviderConfig, + integration_id: impl Into, + user_id: impl Into, + redirect_uri: impl Into, + scopes: impl IntoIterator>, + ) -> Result { + let integration_id = integration_id.into(); + let endpoint = config.authorization_endpoint.as_ref().ok_or_else(|| { + IntegrationError::InvalidConfig(format!( + "integration {integration_id:?}: provider has no authorization endpoint configured" + )) + })?; + + let user_id = user_id.into(); + let redirect_uri = redirect_uri.into(); + let scopes: Vec = scopes.into_iter().map(Into::into).collect(); + + let state = random_urlsafe(32); + let code_verifier = random_urlsafe(48); + let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(code_verifier.as_bytes())); + + let mut url = Url::parse(endpoint).map_err(|err| { + IntegrationError::InvalidConfig(format!("invalid authorization endpoint: {err}")) + })?; + url.query_pairs_mut() + .append_pair("response_type", "code") + .append_pair("client_id", &config.client_id) + .append_pair("redirect_uri", &redirect_uri) + .append_pair("scope", &scopes.join(" ")) + .append_pair("state", &state) + .append_pair("code_challenge", &challenge) + .append_pair("code_challenge_method", "S256"); + + let mut pending = self.pending.lock().expect("consent flow lock poisoned"); + let now = Utc::now(); + pending.retain(|_, consent| consent.expires_at > now); + pending.insert( + state.clone(), + PendingConsent { + integration_id, + user_id, + redirect_uri, + scopes, + code_verifier: SecretString::new(code_verifier), + expires_at: now + self.ttl, + }, + ); + + Ok(AuthorizationRedirect { + url: url.to_string(), + state, + }) + } + + /// Validate a provider callback. Consumes the state (single use) and + /// checks expiry plus binding to the initiating user and integration. + /// On success returns the [`ValidatedConsent`] needed for the code + /// exchange. + pub fn validate_callback( + &self, + state: &str, + expected_integration_id: &str, + expected_user_id: &str, + ) -> Result { + let denied = |reason: &str| IntegrationError::Denied { + integration_id: expected_integration_id.to_string(), + reason: reason.to_string(), + }; + + let consent = { + let mut pending = self.pending.lock().expect("consent flow lock poisoned"); + pending.remove(state) + } + .ok_or_else(|| denied("unknown or already-used consent state"))?; + + if consent.expires_at <= Utc::now() { + return Err(denied("consent state expired")); + } + if consent.integration_id != expected_integration_id { + return Err(denied("consent state belongs to a different integration")); + } + if consent.user_id != expected_user_id { + return Err(denied("consent state belongs to a different user")); + } + + Ok(ValidatedConsent { + integration_id: consent.integration_id, + user_id: consent.user_id, + redirect_uri: consent.redirect_uri, + scopes: consent.scopes, + code_verifier: consent.code_verifier, + }) + } +} + +/// A validated consent callback, ready for the authorization-code exchange. +pub struct ValidatedConsent { + pub integration_id: String, + pub user_id: String, + pub redirect_uri: String, + pub scopes: Vec, + code_verifier: SecretString, +} + +impl std::fmt::Debug for ValidatedConsent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ValidatedConsent") + .field("integration_id", &self.integration_id) + .field("user_id", &self.user_id) + .field("redirect_uri", &self.redirect_uri) + .field("scopes", &self.scopes) + .field("code_verifier", &self.code_verifier) + .finish() + } +} + +impl ValidatedConsent { + /// Exchange the authorization `code` for tokens, store the refresh-token + /// grant, and return the initial access-token lease. + /// + /// The PKCE verifier and the redirect URI bound at `begin` time are sent + /// with the exchange; the grant is persisted with the originally + /// requested scopes. + pub async fn exchange_code( + self, + config: &OAuth2ProviderConfig, + transport: &Arc, + grants: &Arc, + code: &str, + ) -> Result { + let mut params = vec![ + ("grant_type", "authorization_code".to_string()), + ("code", code.to_string()), + ("redirect_uri", self.redirect_uri.clone()), + ("client_id", config.client_id.clone()), + ( + "code_verifier", + self.code_verifier.expose_secret().to_string(), + ), + ]; + if let Some(secret) = &config.client_secret { + params.push(("client_secret", secret.expose_secret().to_string())); + } + let body = + serde_urlencoded::to_string(¶ms).map_err(|err| IntegrationError::Provider { + integration_id: self.integration_id.clone(), + reason: format!("failed to encode code exchange: {err}"), + })?; + + let request = TransportRequest::new(Method::POST, &config.token_endpoint) + .header("content-type", "application/x-www-form-urlencoded") + .header("accept", "application/json") + .body(ras_transport_core::RequestBody::Bytes(body.into())); + + let response = + transport + .execute(request) + .await + .map_err(|err| IntegrationError::Provider { + integration_id: self.integration_id.clone(), + reason: format!("token endpoint unreachable: {err}"), + })?; + let status = response.status(); + let bytes = response + .bytes() + .await + .map_err(|err| IntegrationError::Provider { + integration_id: self.integration_id.clone(), + reason: format!("failed to read exchange response: {err}"), + })?; + if !status.is_success() { + return Err(IntegrationError::Denied { + integration_id: self.integration_id.clone(), + reason: format!("code exchange failed with status {status}"), + }); + } + + #[derive(serde::Deserialize)] + struct ExchangeResponse { + access_token: String, + #[serde(default)] + expires_in: Option, + #[serde(default)] + refresh_token: Option, + } + let exchange: ExchangeResponse = + serde_json::from_slice(&bytes).map_err(|err| IntegrationError::Provider { + integration_id: self.integration_id.clone(), + reason: format!("malformed exchange response: {err}"), + })?; + + let refresh_token = exchange + .refresh_token + .ok_or_else(|| IntegrationError::Provider { + integration_id: self.integration_id.clone(), + reason: "provider returned no refresh token; cannot store a grant".to_string(), + })?; + + grants + .put_user_grant(UserGrant { + integration_id: self.integration_id.clone(), + user_id: self.user_id.clone(), + refresh_token: SecretString::new(refresh_token), + scopes: self.scopes.clone(), + }) + .await?; + + Ok(TokenLease { + access_token: SecretString::new(exchange.access_token), + expires_at: exchange + .expires_in + .map(|seconds| Utc::now() + Duration::seconds(seconds)), + scopes: self.scopes, + }) + } +} + +fn random_urlsafe(bytes: usize) -> String { + let mut buf = vec![0u8; bytes]; + rand::thread_rng().fill_bytes(&mut buf); + URL_SAFE_NO_PAD.encode(buf) +} diff --git a/crates/integration/ras-integration-oauth2/src/lib.rs b/crates/integration/ras-integration-oauth2/src/lib.rs new file mode 100644 index 0000000..75bb40d --- /dev/null +++ b/crates/integration/ras-integration-oauth2/src/lib.rs @@ -0,0 +1,30 @@ +//! OAuth2/OIDC token source for the RAS outbound token framework. +//! +//! Implements [`ras_integration_core::TokenSource`] for external OAuth2 +//! providers: +//! +//! - **User subjects** use the refresh-token flow against grants stored in a +//! [`ras_integration_core::GrantStore`], with scope subset-checks against +//! the stored consent and transactional refresh-token rotation. +//! - **Service subjects** use client credentials, forwarding the requested +//! audience for providers (e.g. Auth0) that support it. +//! - Missing or dead grants surface as +//! [`ras_integration_core::IntegrationError::ConsentRequired`], never a +//! silent fallback. +//! +//! [`ConsentFlow`] provides the consent-side helpers: PKCE S256 +//! authorization URLs with opaque single-use expiring `state` bound to the +//! initiating user/integration/redirect/scopes, callback validation, and +//! the authorization-code exchange that persists the grant. +//! +//! All provider traffic goes through `ras-transport-core`'s +//! [`ras_transport_core::HttpTransport`], so tests and examples can run a +//! fake provider in-process. + +mod config; +mod consent; +mod source; + +pub use config::OAuth2ProviderConfig; +pub use consent::{AuthorizationRedirect, ConsentFlow, ValidatedConsent}; +pub use source::OAuth2TokenSource; diff --git a/crates/integration/ras-integration-oauth2/src/source.rs b/crates/integration/ras-integration-oauth2/src/source.rs new file mode 100644 index 0000000..fe1aef4 --- /dev/null +++ b/crates/integration/ras-integration-oauth2/src/source.rs @@ -0,0 +1,270 @@ +//! The OAuth2 token source: refresh-token and client-credentials flows. + +use std::sync::Arc; + +use async_trait::async_trait; +use chrono::{Duration, Utc}; +use ras_integration_core::{ + GrantStore, IntegrationError, SecretString, TokenFamily, TokenLease, TokenRequest, TokenSource, + TokenSubject, UserGrant, +}; +use ras_transport_core::http::Method; +use ras_transport_core::{HttpTransport, TransportRequest}; +use serde::Deserialize; + +use crate::config::OAuth2ProviderConfig; + +#[derive(Deserialize)] +struct TokenEndpointSuccess { + access_token: String, + #[serde(default)] + expires_in: Option, + #[serde(default)] + refresh_token: Option, + #[serde(default)] + scope: Option, +} + +#[derive(Deserialize)] +struct TokenEndpointFailure { + error: String, + #[serde(default)] + error_description: Option, +} + +/// A [`TokenSource`] backed by an external OAuth2/OIDC provider. +/// +/// - [`TokenSubject::User`]: refresh-token flow against a stored +/// [`UserGrant`]. Requested scopes are subset-checked against the grant; +/// broader requests return [`IntegrationError::ConsentRequired`]. Rotated +/// refresh tokens are persisted back to the [`GrantStore`] before the +/// lease is returned, and persistence failures surface as errors. +/// - [`TokenSubject::Service`]: client-credentials flow (requires a client +/// secret). The request audience is forwarded as the `audience` parameter +/// for providers that support it. +/// - [`TokenSubject::ServiceAccount`]: not supported by external providers +/// in v1; fails closed. +pub struct OAuth2TokenSource { + config: OAuth2ProviderConfig, + transport: Arc, + grants: Arc, +} + +impl OAuth2TokenSource { + pub fn new( + config: OAuth2ProviderConfig, + transport: Arc, + grants: Arc, + ) -> Result { + config.validate()?; + Ok(Self { + config, + transport, + grants, + }) + } + + async fn token_endpoint_request( + &self, + integration_id: &str, + params: Vec<(&str, String)>, + subject_user: Option<&str>, + requested_scopes: &[String], + ) -> Result { + let body = + serde_urlencoded::to_string(¶ms).map_err(|err| IntegrationError::Provider { + integration_id: integration_id.to_string(), + reason: format!("failed to encode token request: {err}"), + })?; + + let request = TransportRequest::new(Method::POST, &self.config.token_endpoint) + .header("content-type", "application/x-www-form-urlencoded") + .header("accept", "application/json") + .body(ras_transport_core::RequestBody::Bytes(body.into())); + + let response = + self.transport + .execute(request) + .await + .map_err(|err| IntegrationError::Provider { + integration_id: integration_id.to_string(), + reason: format!("token endpoint unreachable: {err}"), + })?; + + let status = response.status(); + let bytes = response + .bytes() + .await + .map_err(|err| IntegrationError::Provider { + integration_id: integration_id.to_string(), + reason: format!("failed to read token response: {err}"), + })?; + + if status.is_success() { + return serde_json::from_slice(&bytes).map_err(|err| IntegrationError::Provider { + integration_id: integration_id.to_string(), + reason: format!("malformed token response: {err}"), + }); + } + + // 4xx: interpret the standard OAuth error body. Anything else (or an + // unparseable body) is a provider failure. + if status.is_client_error() + && let Ok(failure) = serde_json::from_slice::(&bytes) + { + return Err(match (failure.error.as_str(), subject_user) { + // The stored grant is dead (revoked/expired): the user must + // re-consent. + ("invalid_grant", Some(user_id)) => IntegrationError::ConsentRequired { + integration_id: integration_id.to_string(), + user_id: user_id.to_string(), + missing_scopes: requested_scopes.to_vec(), + }, + _ => IntegrationError::Denied { + integration_id: integration_id.to_string(), + reason: match failure.error_description { + Some(description) => format!("{}: {description}", failure.error), + None => failure.error, + }, + }, + }); + } + + Err(IntegrationError::Provider { + integration_id: integration_id.to_string(), + reason: format!("token endpoint returned status {status}"), + }) + } + + fn lease_from(&self, success: TokenEndpointSuccess) -> TokenLease { + TokenLease { + access_token: SecretString::new(success.access_token), + expires_at: success + .expires_in + .map(|seconds| Utc::now() + Duration::seconds(seconds)), + scopes: success + .scope + .map(|scope| scope.split_whitespace().map(str::to_string).collect()) + .unwrap_or_default(), + } + } + + async fn user_refresh_flow( + &self, + request: &TokenRequest, + user_id: &str, + ) -> Result { + let grant = self + .grants + .get_user_grant(&request.integration_id, user_id) + .await? + .ok_or_else(|| IntegrationError::ConsentRequired { + integration_id: request.integration_id.clone(), + user_id: user_id.to_string(), + missing_scopes: request.scopes.clone(), + })?; + + let missing: Vec = request + .scopes + .iter() + .filter(|scope| !grant.scopes.contains(scope)) + .cloned() + .collect(); + if !missing.is_empty() { + return Err(IntegrationError::ConsentRequired { + integration_id: request.integration_id.clone(), + user_id: user_id.to_string(), + missing_scopes: missing, + }); + } + + let mut params = vec![ + ("grant_type", "refresh_token".to_string()), + ( + "refresh_token", + grant.refresh_token.expose_secret().to_string(), + ), + ("client_id", self.config.client_id.clone()), + ]; + if let Some(secret) = &self.config.client_secret { + params.push(("client_secret", secret.expose_secret().to_string())); + } + if !request.scopes.is_empty() { + params.push(("scope", request.scopes.join(" "))); + } + + let success = self + .token_endpoint_request( + &request.integration_id, + params, + Some(user_id), + &request.scopes, + ) + .await?; + + // Refresh-token rotation: persist the new grant before handing out + // the lease. A failed save surfaces as an error — silently losing a + // rotated refresh token would brick the stored grant. + if let Some(rotated) = &success.refresh_token { + self.grants + .put_user_grant(UserGrant { + integration_id: grant.integration_id.clone(), + user_id: grant.user_id.clone(), + refresh_token: SecretString::new(rotated.clone()), + scopes: grant.scopes.clone(), + }) + .await?; + } + + Ok(self.lease_from(success)) + } + + async fn client_credentials_flow( + &self, + request: &TokenRequest, + ) -> Result { + let secret = self.config.client_secret.as_ref().ok_or_else(|| { + IntegrationError::InvalidConfig(format!( + "integration {:?}: client-credentials flow requires a client secret", + request.integration_id + )) + })?; + + let mut params = vec![ + ("grant_type", "client_credentials".to_string()), + ("client_id", self.config.client_id.clone()), + ("client_secret", secret.expose_secret().to_string()), + ]; + if !request.scopes.is_empty() { + params.push(("scope", request.scopes.join(" "))); + } + if let Some(audience) = &request.audience { + params.push(("audience", audience.clone())); + } + + let success = self + .token_endpoint_request(&request.integration_id, params, None, &request.scopes) + .await?; + Ok(self.lease_from(success)) + } +} + +#[async_trait] +impl TokenSource for OAuth2TokenSource { + fn family(&self) -> TokenFamily { + TokenFamily::OAuth2 + } + + async fn issue_token(&self, request: &TokenRequest) -> Result { + match &request.subject { + TokenSubject::User { user_id } => self.user_refresh_flow(request, user_id).await, + TokenSubject::Service => self.client_credentials_flow(request).await, + TokenSubject::ServiceAccount { .. } => Err(IntegrationError::Denied { + integration_id: request.integration_id.clone(), + reason: "external OAuth2 providers do not support RAS service-account \ + principals; use a Service (client-credentials) or User subject" + .to_string(), + }), + } + } +} diff --git a/crates/integration/ras-integration-oauth2/tests/oauth2_tests.rs b/crates/integration/ras-integration-oauth2/tests/oauth2_tests.rs new file mode 100644 index 0000000..6597585 --- /dev/null +++ b/crates/integration/ras-integration-oauth2/tests/oauth2_tests.rs @@ -0,0 +1,554 @@ +//! OAuth2 token source and consent flow tests against an in-process fake +//! provider transport. + +use std::collections::{HashMap, VecDeque}; +use std::sync::Arc; + +use async_trait::async_trait; +use bytes::Bytes; +use chrono::Duration; +use ras_integration_core::{ + GrantStore, InMemoryGrantStore, IntegrationError, SecretString, TokenRequest, TokenSource, + TokenSubject, UserGrant, +}; +use ras_integration_oauth2::{ConsentFlow, OAuth2ProviderConfig, OAuth2TokenSource}; +use ras_transport_core::http::{HeaderMap, StatusCode}; +use ras_transport_core::{ + HttpTransport, RequestBody, TransportError, TransportRequest, TransportResponse, + byte_stream_from, +}; +use tokio::sync::Mutex; + +const TOKEN_ENDPOINT: &str = "https://provider.test/oauth/token"; + +/// Fake provider: scripted responses, records decoded form bodies. +#[derive(Default)] +struct FakeProvider { + responses: Mutex>, + requests: Mutex>>, +} + +impl FakeProvider { + async fn push_response(&self, status: StatusCode, body: serde_json::Value) { + self.responses.lock().await.push_back((status, body)); + } + + async fn requests(&self) -> Vec> { + self.requests.lock().await.clone() + } +} + +#[async_trait] +impl HttpTransport for FakeProvider { + async fn execute( + &self, + request: TransportRequest, + ) -> Result { + let params = match &request.body { + RequestBody::Bytes(bytes) => { + serde_urlencoded::from_bytes::>(bytes) + .unwrap_or_default() + .into_iter() + .collect() + } + _ => HashMap::new(), + }; + self.requests.lock().await.push(params); + + let (status, body) = self + .responses + .lock() + .await + .pop_front() + .unwrap_or((StatusCode::INTERNAL_SERVER_ERROR, serde_json::json!({}))); + Ok(TransportResponse::new( + status, + HeaderMap::new(), + byte_stream_from(futures::stream::iter(vec![Ok(Bytes::from( + serde_json::to_vec(&body).unwrap(), + ))])), + )) + } +} + +fn provider_config() -> OAuth2ProviderConfig { + OAuth2ProviderConfig::new(TOKEN_ENDPOINT, "ras-client") + .unwrap() + .with_authorization_endpoint("https://provider.test/oauth/authorize") + .unwrap() + .with_client_secret("ras-client-secret") +} + +async fn seeded_grants() -> Arc { + let grants = Arc::new(InMemoryGrantStore::new()); + grants + .put_user_grant(UserGrant { + integration_id: "google-calendar".to_string(), + user_id: "alice".to_string(), + refresh_token: SecretString::new("stored-refresh-token"), + scopes: vec![ + "calendar.readonly".to_string(), + "calendar.write".to_string(), + ], + }) + .await + .unwrap(); + grants +} + +fn user_request(scopes: &[&str]) -> TokenRequest { + TokenRequest { + integration_id: "google-calendar".to_string(), + subject: TokenSubject::User { + user_id: "alice".to_string(), + }, + scopes: scopes.iter().map(|s| s.to_string()).collect(), + audience: None, + force_refresh: false, + } +} + +fn source_with(provider: Arc, grants: Arc) -> OAuth2TokenSource { + OAuth2TokenSource::new(provider_config(), provider, grants).unwrap() +} + +#[tokio::test] +async fn refresh_flow_sends_expected_params_and_returns_lease() { + let provider = Arc::new(FakeProvider::default()); + provider + .push_response( + StatusCode::OK, + serde_json::json!({ + "access_token": "fresh-access-token", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "calendar.readonly" + }), + ) + .await; + let source = source_with(provider.clone(), seeded_grants().await); + + let lease = source + .issue_token(&user_request(&["calendar.readonly"])) + .await + .unwrap(); + assert_eq!(lease.access_token.expose_secret(), "fresh-access-token"); + assert!(lease.expires_at.is_some()); + assert_eq!(lease.scopes, vec!["calendar.readonly"]); + + let requests = provider.requests().await; + assert_eq!(requests.len(), 1); + let params = &requests[0]; + assert_eq!(params["grant_type"], "refresh_token"); + assert_eq!(params["refresh_token"], "stored-refresh-token"); + assert_eq!(params["client_id"], "ras-client"); + assert_eq!(params["client_secret"], "ras-client-secret"); + assert_eq!(params["scope"], "calendar.readonly"); +} + +#[tokio::test] +async fn missing_grant_is_consent_required_without_provider_call() { + let provider = Arc::new(FakeProvider::default()); + let source = source_with(provider.clone(), Arc::new(InMemoryGrantStore::new())); + + let err = source + .issue_token(&user_request(&["calendar.readonly"])) + .await + .unwrap_err(); + assert!(matches!( + err, + IntegrationError::ConsentRequired { user_id, .. } if user_id == "alice" + )); + assert!(provider.requests().await.is_empty()); +} + +#[tokio::test] +async fn scopes_beyond_grant_are_consent_required_with_missing_scopes() { + let provider = Arc::new(FakeProvider::default()); + let source = source_with(provider.clone(), seeded_grants().await); + + let err = source + .issue_token(&user_request(&["calendar.readonly", "drive.readonly"])) + .await + .unwrap_err(); + let IntegrationError::ConsentRequired { missing_scopes, .. } = err else { + panic!("expected ConsentRequired, got {err:?}"); + }; + assert_eq!(missing_scopes, vec!["drive.readonly"]); + assert!(provider.requests().await.is_empty()); +} + +#[tokio::test] +async fn invalid_grant_response_maps_to_consent_required() { + let provider = Arc::new(FakeProvider::default()); + provider + .push_response( + StatusCode::BAD_REQUEST, + serde_json::json!({"error": "invalid_grant", "error_description": "revoked"}), + ) + .await; + let source = source_with(provider, seeded_grants().await); + + let err = source + .issue_token(&user_request(&["calendar.readonly"])) + .await + .unwrap_err(); + assert!(matches!(err, IntegrationError::ConsentRequired { .. })); +} + +#[tokio::test] +async fn invalid_scope_response_maps_to_denied() { + let provider = Arc::new(FakeProvider::default()); + provider + .push_response( + StatusCode::BAD_REQUEST, + serde_json::json!({"error": "invalid_scope"}), + ) + .await; + let source = source_with(provider, seeded_grants().await); + + let err = source + .issue_token(&user_request(&["calendar.readonly"])) + .await + .unwrap_err(); + assert!( + matches!(err, IntegrationError::Denied { reason, .. } if reason.contains("invalid_scope")) + ); +} + +#[tokio::test] +async fn provider_5xx_and_malformed_responses_are_provider_errors() { + let provider = Arc::new(FakeProvider::default()); + provider + .push_response(StatusCode::INTERNAL_SERVER_ERROR, serde_json::json!({})) + .await; + let source = source_with(provider.clone(), seeded_grants().await); + let err = source + .issue_token(&user_request(&["calendar.readonly"])) + .await + .unwrap_err(); + assert!(matches!(err, IntegrationError::Provider { .. })); + + provider + .push_response(StatusCode::OK, serde_json::json!({"unexpected": true})) + .await; + let err = source + .issue_token(&user_request(&["calendar.readonly"])) + .await + .unwrap_err(); + assert!(matches!(err, IntegrationError::Provider { .. })); +} + +#[tokio::test] +async fn rotated_refresh_token_is_persisted_before_lease_returns() { + let provider = Arc::new(FakeProvider::default()); + provider + .push_response( + StatusCode::OK, + serde_json::json!({ + "access_token": "fresh-access-token", + "expires_in": 3600, + "refresh_token": "rotated-refresh-token" + }), + ) + .await; + let grants = seeded_grants().await; + let source = source_with(provider, grants.clone()); + + source + .issue_token(&user_request(&["calendar.readonly"])) + .await + .unwrap(); + + let stored = grants + .get_user_grant("google-calendar", "alice") + .await + .unwrap() + .unwrap(); + assert_eq!( + stored.refresh_token.expose_secret(), + "rotated-refresh-token" + ); + // Consented scopes are preserved through rotation. + assert_eq!(stored.scopes.len(), 2); +} + +/// Grant store whose writes fail — proves rotation persistence failures +/// surface instead of being swallowed. +struct ReadOnlyGrantStore(Arc); + +#[async_trait] +impl GrantStore for ReadOnlyGrantStore { + async fn get_user_grant( + &self, + integration_id: &str, + user_id: &str, + ) -> Result, IntegrationError> { + self.0.get_user_grant(integration_id, user_id).await + } + + async fn put_user_grant(&self, _grant: UserGrant) -> Result<(), IntegrationError> { + Err(IntegrationError::GrantStore( + "simulated write failure".to_string(), + )) + } + + async fn remove_user_grant( + &self, + integration_id: &str, + user_id: &str, + ) -> Result { + self.0.remove_user_grant(integration_id, user_id).await + } +} + +#[tokio::test] +async fn failed_rotation_persistence_surfaces_as_error() { + let provider = Arc::new(FakeProvider::default()); + provider + .push_response( + StatusCode::OK, + serde_json::json!({ + "access_token": "fresh-access-token", + "refresh_token": "rotated-refresh-token" + }), + ) + .await; + let grants: Arc = Arc::new(ReadOnlyGrantStore(seeded_grants().await)); + let source = OAuth2TokenSource::new(provider_config(), provider, grants).unwrap(); + + let err = source + .issue_token(&user_request(&["calendar.readonly"])) + .await + .unwrap_err(); + assert!(matches!(err, IntegrationError::GrantStore(_))); +} + +#[tokio::test] +async fn client_credentials_flow_sends_secret_and_audience() { + let provider = Arc::new(FakeProvider::default()); + provider + .push_response( + StatusCode::OK, + serde_json::json!({"access_token": "svc-token", "expires_in": 600}), + ) + .await; + let source = source_with(provider.clone(), Arc::new(InMemoryGrantStore::new())); + + let lease = source + .issue_token(&TokenRequest { + integration_id: "partner-api".to_string(), + subject: TokenSubject::Service, + scopes: vec!["partner:read".to_string()], + audience: Some("https://partner.example.com/api".to_string()), + force_refresh: false, + }) + .await + .unwrap(); + assert_eq!(lease.access_token.expose_secret(), "svc-token"); + + let requests = provider.requests().await; + let params = &requests[0]; + assert_eq!(params["grant_type"], "client_credentials"); + assert_eq!(params["client_secret"], "ras-client-secret"); + assert_eq!(params["audience"], "https://partner.example.com/api"); + assert_eq!(params["scope"], "partner:read"); +} + +#[tokio::test] +async fn client_credentials_without_secret_fails_closed() { + let provider = Arc::new(FakeProvider::default()); + let config = OAuth2ProviderConfig::new(TOKEN_ENDPOINT, "ras-client").unwrap(); + let source = OAuth2TokenSource::new( + config, + provider.clone(), + Arc::new(InMemoryGrantStore::new()), + ) + .unwrap(); + + let err = source + .issue_token(&TokenRequest { + integration_id: "partner-api".to_string(), + subject: TokenSubject::Service, + scopes: vec![], + audience: None, + force_refresh: false, + }) + .await + .unwrap_err(); + assert!(matches!(err, IntegrationError::InvalidConfig(_))); + assert!(provider.requests().await.is_empty()); +} + +#[tokio::test] +async fn service_account_subjects_are_rejected() { + let provider = Arc::new(FakeProvider::default()); + let source = source_with(provider, Arc::new(InMemoryGrantStore::new())); + let err = source + .issue_token(&TokenRequest { + integration_id: "partner-api".to_string(), + subject: TokenSubject::ServiceAccount { + service_account_id: "bot-1".to_string(), + }, + scopes: vec![], + audience: None, + force_refresh: false, + }) + .await + .unwrap_err(); + assert!(matches!(err, IntegrationError::Denied { .. })); +} + +// --- Consent flow --- + +#[test] +fn begin_builds_pkce_authorization_url() { + let flow = ConsentFlow::new(Duration::minutes(10)); + let redirect = flow + .begin( + &provider_config(), + "google-calendar", + "alice", + "https://app.example.com/oauth/callback", + ["calendar.readonly"], + ) + .unwrap(); + + let url = url::Url::parse(&redirect.url).unwrap(); + let params: HashMap<_, _> = url.query_pairs().into_owned().collect(); + assert_eq!(params["response_type"], "code"); + assert_eq!(params["client_id"], "ras-client"); + assert_eq!( + params["redirect_uri"], + "https://app.example.com/oauth/callback" + ); + assert_eq!(params["scope"], "calendar.readonly"); + assert_eq!(params["code_challenge_method"], "S256"); + assert_eq!(params["state"], redirect.state); + assert!(!params["code_challenge"].is_empty()); +} + +#[test] +fn callback_state_is_single_use_and_bound_to_user_and_integration() { + let flow = ConsentFlow::new(Duration::minutes(10)); + let config = provider_config(); + let redirect = flow + .begin(&config, "google-calendar", "alice", "https://cb", ["s"]) + .unwrap(); + + // Wrong integration / wrong user fail and consume nothing... but state + // is single-use, so test the failures on fresh states. + let wrong_user = flow + .begin(&config, "google-calendar", "alice", "https://cb", ["s"]) + .unwrap(); + assert!( + flow.validate_callback(&wrong_user.state, "google-calendar", "mallory") + .is_err() + ); + + let wrong_integration = flow + .begin(&config, "google-calendar", "alice", "https://cb", ["s"]) + .unwrap(); + assert!( + flow.validate_callback(&wrong_integration.state, "github", "alice") + .is_err() + ); + + // Unknown state fails. + assert!( + flow.validate_callback("forged-state", "google-calendar", "alice") + .is_err() + ); + + // Correct binding succeeds exactly once. + assert!( + flow.validate_callback(&redirect.state, "google-calendar", "alice") + .is_ok() + ); + assert!( + flow.validate_callback(&redirect.state, "google-calendar", "alice") + .is_err(), + "state must be single-use" + ); +} + +#[test] +fn expired_state_is_rejected() { + let flow = ConsentFlow::new(Duration::seconds(-1)); + let redirect = flow + .begin( + &provider_config(), + "google-calendar", + "alice", + "https://cb", + ["s"], + ) + .unwrap(); + let err = flow + .validate_callback(&redirect.state, "google-calendar", "alice") + .unwrap_err(); + assert!(matches!(err, IntegrationError::Denied { .. })); +} + +#[tokio::test] +async fn full_consent_exchange_stores_grant_and_enables_refresh_flow() { + let provider = Arc::new(FakeProvider::default()); + let transport: Arc = provider.clone(); + let grants: Arc = Arc::new(InMemoryGrantStore::new()); + let config = provider_config(); + let flow = ConsentFlow::new(Duration::minutes(10)); + + let redirect = flow + .begin( + &config, + "google-calendar", + "alice", + "https://app.example.com/cb", + ["calendar.readonly"], + ) + .unwrap(); + let consent = flow + .validate_callback(&redirect.state, "google-calendar", "alice") + .unwrap(); + + provider + .push_response( + StatusCode::OK, + serde_json::json!({ + "access_token": "first-access-token", + "expires_in": 3600, + "refresh_token": "first-refresh-token" + }), + ) + .await; + let lease = consent + .exchange_code(&config, &transport, &grants, "auth-code-123") + .await + .unwrap(); + assert_eq!(lease.access_token.expose_secret(), "first-access-token"); + + // The exchange sent the code, verifier, and bound redirect URI. + let requests = provider.requests().await; + let params = &requests[0]; + assert_eq!(params["grant_type"], "authorization_code"); + assert_eq!(params["code"], "auth-code-123"); + assert_eq!(params["redirect_uri"], "https://app.example.com/cb"); + assert!(!params["code_verifier"].is_empty()); + + // The stored grant now powers the regular refresh flow. + provider + .push_response( + StatusCode::OK, + serde_json::json!({"access_token": "second-access-token", "expires_in": 3600}), + ) + .await; + let source = OAuth2TokenSource::new(config, transport, grants).unwrap(); + let lease = source + .issue_token(&user_request(&["calendar.readonly"])) + .await + .unwrap(); + assert_eq!(lease.access_token.expose_secret(), "second-access-token"); + + let requests = provider.requests().await; + assert_eq!(requests[1]["refresh_token"], "first-refresh-token"); +} diff --git a/crates/integration/ras-integration-ras/Cargo.toml b/crates/integration/ras-integration-ras/Cargo.toml new file mode 100644 index 0000000..a1adc7f --- /dev/null +++ b/crates/integration/ras-integration-ras/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "ras-integration-ras" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +description = "RasInternalTokenSource: RAS-issued internal service tokens for the outbound token framework, backed by the RAS authorization control plane" +keywords = ["api", "auth", "authorization", "tokens", "services"] +categories = ["authentication", "web-programming"] +license = "MIT OR Apache-2.0" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" +readme = "README.md" + +[dependencies] +ras-authorization-core = { path = "../../authorization/ras-authorization-core", version = "0.1.0" } +ras-integration-core = { path = "../ras-integration-core", version = "0.1.0" } +ras-transport-core = { path = "../../core/ras-transport-core", version = "0.1.0" } + +async-trait = { workspace = true } +chrono = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } + +[dev-dependencies] +ras-authorization-token = { path = "../../authorization/ras-authorization-token", version = "0.1.0" } +ras-permission-manifest = { path = "../../specs/ras-permission-manifest", version = "0.1.0" } + +bytes = { workspace = true } +futures = { workspace = true } diff --git a/crates/integration/ras-integration-ras/README.md b/crates/integration/ras-integration-ras/README.md new file mode 100644 index 0000000..9da28f2 --- /dev/null +++ b/crates/integration/ras-integration-ras/README.md @@ -0,0 +1,15 @@ +# ras-integration-ras + +`RasInternalTokenSource`: the bridge between the RAS outbound token +framework (`ras-integration-core`) and the RAS authorization control plane +(`ras-authorization-core`). + +- Holds no signing keys and never mints locally — every lease comes from a + successful authorization decision by the RAS authority. +- `EmbeddedAuthority` calls the issuer in-process (embedded preset); + `HttpAuthority` posts to a central authority's `POST /auth/token`. +- v1 issues service-as-service tokens only; user-delegated and + service-account requests fail closed before any authority call, and the + request/cache model already distinguishes principal modes for later. +- Authority denials surface as typed `Denied` errors; authority/transport + failures as `Provider` errors. diff --git a/crates/integration/ras-integration-ras/src/lib.rs b/crates/integration/ras-integration-ras/src/lib.rs new file mode 100644 index 0000000..c6fbc7f --- /dev/null +++ b/crates/integration/ras-integration-ras/src/lib.rs @@ -0,0 +1,224 @@ +//! RAS-internal token source for the outbound token framework. +//! +//! [`RasInternalTokenSource`] implements +//! [`ras_integration_core::TokenSource`] by requesting internal service +//! tokens from the RAS authorization control plane +//! (`ras-authorization-core`). It holds **no signing keys and never mints +//! locally**: every lease is the result of a successful authorization and +//! issuance decision by the authority. +//! +//! Two authority transports: +//! +//! - [`EmbeddedAuthority`] calls a [`TokenIssuer`] in-process (the embedded +//! deployment preset). +//! - [`HttpAuthority`] posts to a central authority's `POST /auth/token` +//! route through `ras-transport-core`. +//! +//! v1 implements service-as-service issuance: the source authenticates with +//! its own service identity proof and only accepts +//! [`TokenSubject::Service`] requests. User-delegated and service-account +//! requests fail closed (the request/cache model already distinguishes +//! them, so adding delegation later is additive). + +use std::sync::Arc; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use ras_authorization_core::{AuthzError, InternalTokenRequest, ServiceIdentityProof, TokenIssuer}; +use ras_integration_core::{ + IntegrationError, SecretString, TokenFamily, TokenLease, TokenRequest, TokenSource, + TokenSubject, +}; +use ras_transport_core::http::Method; +use ras_transport_core::{HttpTransport, TransportRequest}; + +/// The authority-side result of an issuance request. +#[derive(Debug, serde::Deserialize)] +pub struct AuthorityTokenResponse { + pub token: String, + pub expires_at: DateTime, +} + +/// How the token source reaches the RAS authority. +#[async_trait] +pub trait AuthorityClient: Send + Sync { + async fn issue( + &self, + integration_id: &str, + request: InternalTokenRequest, + ) -> Result; +} + +/// In-process authority access for the embedded deployment preset. +pub struct EmbeddedAuthority { + issuer: Arc, +} + +impl EmbeddedAuthority { + pub fn new(issuer: Arc) -> Self { + Self { issuer } + } +} + +#[async_trait] +impl AuthorityClient for EmbeddedAuthority { + async fn issue( + &self, + integration_id: &str, + request: InternalTokenRequest, + ) -> Result { + let issued = self + .issuer + .issue_internal_token(request) + .await + .map_err(|err| map_authz_error(integration_id, err))?; + Ok(AuthorityTokenResponse { + token: issued.token, + expires_at: issued.expires_at, + }) + } +} + +fn map_authz_error(integration_id: &str, err: AuthzError) -> IntegrationError { + match err { + AuthzError::Token(_) | AuthzError::Store(_) => IntegrationError::Provider { + integration_id: integration_id.to_string(), + reason: err.to_string(), + }, + denied => IntegrationError::Denied { + integration_id: integration_id.to_string(), + reason: denied.to_string(), + }, + } +} + +/// HTTP authority access for the central-authority deployment preset. +/// Posts [`InternalTokenRequest`] JSON to the authority's token route. +pub struct HttpAuthority { + transport: Arc, + /// Full URL of the authority's token endpoint + /// (e.g. `https://auth.internal/auth/token`). + token_url: String, +} + +impl HttpAuthority { + pub fn new(transport: Arc, token_url: impl Into) -> Self { + Self { + transport, + token_url: token_url.into(), + } + } +} + +#[async_trait] +impl AuthorityClient for HttpAuthority { + async fn issue( + &self, + integration_id: &str, + request: InternalTokenRequest, + ) -> Result { + let provider_error = |reason: String| IntegrationError::Provider { + integration_id: integration_id.to_string(), + reason, + }; + + let http_request = TransportRequest::new(Method::POST, &self.token_url) + .json(&request) + .map_err(|err| provider_error(format!("failed to encode token request: {err}")))?; + let response = self + .transport + .execute(http_request) + .await + .map_err(|err| provider_error(format!("authority unreachable: {err}")))?; + + let status = response.status(); + let bytes = response + .bytes() + .await + .map_err(|err| provider_error(format!("failed to read authority response: {err}")))?; + + if status.is_success() { + return serde_json::from_slice(&bytes) + .map_err(|err| provider_error(format!("malformed authority response: {err}"))); + } + + if status.is_client_error() { + #[derive(serde::Deserialize)] + struct AuthorityError { + error: String, + } + let reason = serde_json::from_slice::(&bytes) + .map(|body| body.error) + .unwrap_or_else(|_| format!("authority returned status {status}")); + return Err(IntegrationError::Denied { + integration_id: integration_id.to_string(), + reason, + }); + } + + Err(provider_error(format!( + "authority returned status {status}" + ))) + } +} + +/// [`TokenSource`] producing RAS-issued internal service tokens +/// (family [`TokenFamily::RasInternal`]). +pub struct RasInternalTokenSource { + authority: Arc, + /// This service's identity proof, presented on every issuance request. + proof: ServiceIdentityProof, +} + +impl RasInternalTokenSource { + pub fn new(authority: Arc, proof: ServiceIdentityProof) -> Self { + Self { authority, proof } + } +} + +#[async_trait] +impl TokenSource for RasInternalTokenSource { + fn family(&self) -> TokenFamily { + TokenFamily::RasInternal + } + + async fn issue_token(&self, request: &TokenRequest) -> Result { + // v1: service-as-service only. Other principal modes fail closed + // here, before any authority call. + match &request.subject { + TokenSubject::Service => {} + TokenSubject::User { .. } | TokenSubject::ServiceAccount { .. } => { + return Err(IntegrationError::Denied { + integration_id: request.integration_id.clone(), + reason: "RasInternalTokenSource v1 issues service-as-service tokens only" + .to_string(), + }); + } + } + + let audience = request.audience.clone().ok_or_else(|| { + IntegrationError::InvalidConfig(format!( + "integration {:?}: internal token requests require a target audience", + request.integration_id + )) + })?; + + let response = self + .authority + .issue( + &request.integration_id, + InternalTokenRequest { + proof: self.proof.clone(), + audience, + permissions: request.scopes.clone(), + }, + ) + .await?; + + Ok(TokenLease { + access_token: SecretString::new(response.token), + expires_at: Some(response.expires_at), + scopes: request.scopes.clone(), + }) + } +} diff --git a/crates/integration/ras-integration-ras/tests/internal_source_tests.rs b/crates/integration/ras-integration-ras/tests/internal_source_tests.rs new file mode 100644 index 0000000..39523ae --- /dev/null +++ b/crates/integration/ras-integration-ras/tests/internal_source_tests.rs @@ -0,0 +1,402 @@ +//! RasInternalTokenSource tests: embedded and HTTP authority modes, plus an +//! end-to-end service-to-service flow through the token manager and a +//! capability-scoped client. + +use std::sync::Arc; + +use async_trait::async_trait; +use bytes::Bytes; +use ras_authorization_core::{ + AudiencePermission, InMemoryAuditSink, InMemoryAuthorizationStore, Principal, + ServiceIdentityProof, ServiceRegistration, StaticSecretVerifier, TokenIssuer, +}; +use ras_authorization_token::{ + AudiencePolicy, SigningKey, TokenType, TokenValidator, ValidationOptions, +}; +use ras_integration_core::{ + AuthorizedHttpClient, IntegrationConfig, IntegrationError, TokenManager, TokenRequest, + TokenSource, TokenSubject, +}; +use ras_integration_ras::{EmbeddedAuthority, HttpAuthority, RasInternalTokenSource}; +use ras_permission_manifest::{ + AuthRequirementInfo, OperationKind, OperationPermissions, PermissionManifest, + ServicePermissions, TransportKind, WireTarget, +}; +use ras_transport_core::http::{HeaderMap, StatusCode}; +use ras_transport_core::{ + HttpTransport, TransportError, TransportRequest, TransportResponse, byte_stream_from, +}; +use tokio::sync::Mutex; + +const ISSUER: &str = "https://auth.internal"; +const BILLING_SECRET: &str = "billing-service-static-secret-32b!!"; + +fn invoice_manifest() -> PermissionManifest { + let operations = ["invoice:read", "invoice:write"] + .iter() + .map(|permission| OperationPermissions { + operation_id: format!("op_{permission}"), + operation_name: format!("op_{permission}"), + kind: OperationKind::RestEndpoint, + wire: WireTarget::Rest { + method: "GET".to_string(), + path: "/invoices".to_string(), + }, + auth: AuthRequirementInfo::from_permission_groups([[*permission]]), + version: None, + canonical_operation_id: None, + }) + .collect(); + PermissionManifest::from_services([ServicePermissions { + service_name: "InvoiceService".to_string(), + transport: TransportKind::Rest, + operations, + }]) +} + +struct Fixture { + issuer: Arc, + audit: Arc, +} + +async fn authority_fixture() -> Fixture { + let store = Arc::new(InMemoryAuthorizationStore::new()); + let verifier = Arc::new(StaticSecretVerifier::new()); + let audit = Arc::new(InMemoryAuditSink::new()); + + for id in ["billing-service", "invoice-service"] { + store + .register_service(ServiceRegistration { + service_id: id.to_string(), + display_name: id.to_string(), + audience: id.to_string(), + enabled: true, + }) + .await + .unwrap(); + } + verifier + .register("billing-service", BILLING_SECRET.as_bytes()) + .await + .unwrap(); + store + .import_manifest("invoice-service", &invoice_manifest()) + .await + .unwrap(); + store + .grant( + Principal::Service { + service_id: "billing-service".to_string(), + }, + AudiencePermission::new("invoice-service", "invoice:read"), + ) + .await + .unwrap(); + + let issuer = Arc::new( + TokenIssuer::builder( + ISSUER, + SigningKey::generate_es256("k1"), + store.clone(), + verifier.clone(), + ) + .audit(audit.clone()) + .build(), + ); + Fixture { issuer, audit } +} + +fn billing_proof() -> ServiceIdentityProof { + ServiceIdentityProof { + service_id: "billing-service".to_string(), + proof: serde_json::json!({ "client_secret": BILLING_SECRET }), + } +} + +fn internal_request(scopes: &[&str]) -> TokenRequest { + TokenRequest { + integration_id: "invoice-service".to_string(), + subject: TokenSubject::Service, + scopes: scopes.iter().map(|s| s.to_string()).collect(), + audience: Some("invoice-service".to_string()), + force_refresh: false, + } +} + +#[tokio::test] +async fn embedded_authority_issues_validatable_internal_tokens() { + let fixture = authority_fixture().await; + let source = RasInternalTokenSource::new( + Arc::new(EmbeddedAuthority::new(fixture.issuer.clone())), + billing_proof(), + ); + + let lease = source + .issue_token(&internal_request(&["invoice:read"])) + .await + .unwrap(); + assert!(lease.expires_at.is_some()); + + let validator = TokenValidator::new( + fixture.issuer.jwks().await, + ValidationOptions::new( + ISSUER, + AudiencePolicy::Exact("invoice-service".to_string()), + vec![TokenType::InternalService], + ), + ); + let claims = validator + .validate(lease.access_token.expose_secret()) + .unwrap(); + assert_eq!(claims.sub, "billing-service"); + assert_eq!(claims.permissions, vec!["invoice:read"]); +} + +#[tokio::test] +async fn authority_denial_surfaces_and_no_token_is_produced() { + let fixture = authority_fixture().await; + let source = RasInternalTokenSource::new( + Arc::new(EmbeddedAuthority::new(fixture.issuer.clone())), + billing_proof(), + ); + + let err = source + .issue_token(&internal_request(&["invoice:write"])) + .await + .unwrap_err(); + assert!( + matches!(err, IntegrationError::Denied { reason, .. } if reason.contains("invoice:write")) + ); +} + +#[tokio::test] +async fn identity_failure_surfaces_as_denied() { + let fixture = authority_fixture().await; + let source = RasInternalTokenSource::new( + Arc::new(EmbeddedAuthority::new(fixture.issuer.clone())), + ServiceIdentityProof { + service_id: "billing-service".to_string(), + proof: serde_json::json!({ "client_secret": "wrong-secret-that-is-32-bytes!!!" }), + }, + ); + let err = source + .issue_token(&internal_request(&["invoice:read"])) + .await + .unwrap_err(); + assert!(matches!(err, IntegrationError::Denied { .. })); +} + +#[tokio::test] +async fn non_service_subjects_fail_closed_before_any_authority_call() { + let fixture = authority_fixture().await; + let source = RasInternalTokenSource::new( + Arc::new(EmbeddedAuthority::new(fixture.issuer.clone())), + billing_proof(), + ); + + for subject in [ + TokenSubject::User { + user_id: "alice".to_string(), + }, + TokenSubject::ServiceAccount { + service_account_id: "bot".to_string(), + }, + ] { + let mut request = internal_request(&["invoice:read"]); + request.subject = subject; + let err = source.issue_token(&request).await.unwrap_err(); + assert!(matches!(err, IntegrationError::Denied { .. })); + } + // The authority never saw a request: no audit events were recorded. + assert!(fixture.audit.events().await.is_empty()); +} + +#[tokio::test] +async fn missing_audience_fails_closed() { + let fixture = authority_fixture().await; + let source = RasInternalTokenSource::new( + Arc::new(EmbeddedAuthority::new(fixture.issuer.clone())), + billing_proof(), + ); + let mut request = internal_request(&[]); + request.audience = None; + let err = source.issue_token(&request).await.unwrap_err(); + assert!(matches!(err, IntegrationError::InvalidConfig(_))); +} + +// --- HTTP authority mode --- + +#[derive(Default)] +struct ScriptedAuthority { + responses: Mutex>, + bodies: Mutex>, +} + +#[async_trait] +impl HttpTransport for ScriptedAuthority { + async fn execute( + &self, + request: TransportRequest, + ) -> Result { + if let ras_transport_core::RequestBody::Bytes(bytes) = &request.body { + self.bodies + .lock() + .await + .push(serde_json::from_slice(bytes).unwrap()); + } + let (status, body) = self.responses.lock().await.remove(0); + Ok(TransportResponse::new( + status, + HeaderMap::new(), + byte_stream_from(futures::stream::iter(vec![Ok(Bytes::from( + serde_json::to_vec(&body).unwrap(), + ))])), + )) + } +} + +#[tokio::test] +async fn http_authority_round_trip_and_error_mapping() { + let transport = Arc::new(ScriptedAuthority::default()); + transport.responses.lock().await.extend([ + ( + StatusCode::OK, + serde_json::json!({ + "token": "signed.jwt.value", + "expires_at": chrono::Utc::now() + chrono::Duration::minutes(5) + }), + ), + ( + StatusCode::FORBIDDEN, + serde_json::json!({"error": "issuance_denied"}), + ), + (StatusCode::INTERNAL_SERVER_ERROR, serde_json::json!({})), + (StatusCode::OK, serde_json::json!({"unexpected": true})), + ]); + + let source = RasInternalTokenSource::new( + Arc::new(HttpAuthority::new( + transport.clone(), + "https://auth.internal/auth/token", + )), + billing_proof(), + ); + + // 200 -> lease. + let lease = source + .issue_token(&internal_request(&["invoice:read"])) + .await + .unwrap(); + assert_eq!(lease.access_token.expose_secret(), "signed.jwt.value"); + + // The posted body is a full InternalTokenRequest. + let bodies = transport.bodies.lock().await.clone(); + assert_eq!(bodies[0]["proof"]["service_id"], "billing-service"); + assert_eq!(bodies[0]["audience"], "invoice-service"); + assert_eq!(bodies[0]["permissions"][0], "invoice:read"); + + // 403 -> Denied with the authority's error code. + let err = source + .issue_token(&internal_request(&["invoice:read"])) + .await + .unwrap_err(); + assert!(matches!(err, IntegrationError::Denied { reason, .. } if reason == "issuance_denied")); + + // 500 -> Provider. + let err = source + .issue_token(&internal_request(&["invoice:read"])) + .await + .unwrap_err(); + assert!(matches!(err, IntegrationError::Provider { .. })); + + // Malformed 200 -> Provider. + let err = source + .issue_token(&internal_request(&["invoice:read"])) + .await + .unwrap_err(); + assert!(matches!(err, IntegrationError::Provider { .. })); +} + +// --- End to end: capability client -> manager -> internal source -> JWKS --- + +#[derive(Default)] +struct CapturingBackend { + authorization: Mutex>, +} + +#[async_trait] +impl HttpTransport for CapturingBackend { + async fn execute( + &self, + request: TransportRequest, + ) -> Result { + let auth = request + .headers + .get(ras_transport_core::http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(); + self.authorization.lock().await.push(auth); + Ok(TransportResponse::new( + StatusCode::OK, + HeaderMap::new(), + byte_stream_from(futures::stream::iter(vec![Ok(Bytes::from_static(b"[]"))])), + )) + } +} + +#[tokio::test] +async fn billing_calls_invoice_with_authority_issued_bearer() { + let fixture = authority_fixture().await; + let source = Arc::new(RasInternalTokenSource::new( + Arc::new(EmbeddedAuthority::new(fixture.issuer.clone())), + billing_proof(), + )); + + let manager = Arc::new( + TokenManager::builder() + .register( + IntegrationConfig::new( + "invoice-service", + ["invoice:read"], + ["http://invoice-service:3000"], + ) + .unwrap() + .with_allowed_audiences(["invoice-service"]), + source, + ) + .unwrap() + .build(), + ); + + let backend = Arc::new(CapturingBackend::default()); + let client = AuthorizedHttpClient::for_service( + backend.clone(), + manager, + "invoice-service", + ["invoice:read"], + ) + .with_audience("invoice-service"); + + client + .get("http://invoice-service:3000/api/invoices") + .await + .unwrap(); + + // The backend received a bearer that validates against the authority's + // JWKS as a single-audience internal token. + let seen = backend.authorization.lock().await.clone(); + let token = seen[0].strip_prefix("Bearer ").unwrap(); + let validator = TokenValidator::new( + fixture.issuer.jwks().await, + ValidationOptions::new( + ISSUER, + AudiencePolicy::Exact("invoice-service".to_string()), + vec![TokenType::InternalService], + ), + ); + let claims = validator.validate(token).unwrap(); + assert_eq!(claims.sub, "billing-service"); + assert_eq!(claims.permissions, vec!["invoice:read"]); + assert!(claims.audience_permissions.is_none()); +} diff --git a/crates/topology/ras-topology-core/Cargo.toml b/crates/topology/ras-topology-core/Cargo.toml new file mode 100644 index 0000000..cc83af8 --- /dev/null +++ b/crates/topology/ras-topology-core/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "ras-topology-core" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +description = "Deployment-agnostic RAS service topology model: validated service graphs, gateway profiles, authorization policy artifacts, and diagrams" +keywords = ["api", "topology", "authorization", "gateway", "codegen"] +categories = ["development-tools", "web-programming"] +license = "MIT OR Apache-2.0" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" +readme = "README.md" + +[dependencies] +ras-permission-manifest = { path = "../../specs/ras-permission-manifest", version = "0.1.0" } + +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +thiserror = { workspace = true } +toml = { workspace = true } + +[dev-dependencies] +ras-authorization-core = { path = "../../authorization/ras-authorization-core", version = "0.1.0" } +ras-authorization-gateway = { path = "../../authorization/ras-authorization-gateway", version = "0.1.0" } +ras-topology-macro = { path = "../ras-topology-macro", version = "0.1.0" } diff --git a/crates/topology/ras-topology-core/README.md b/crates/topology/ras-topology-core/README.md new file mode 100644 index 0000000..7f1e86f --- /dev/null +++ b/crates/topology/ras-topology-core/README.md @@ -0,0 +1,27 @@ +# ras-topology-core + +Deployment-agnostic RAS service topology (issue #15): declare the logical +service graph — services with audiences and generated permission manifests, +gateway profiles with routes, allowed service-to-service edges — validate it +deterministically, and emit the artifacts the rest of the stack consumes: + +- authorization policy JSON (loads into `ras-authorization-core`'s + `ServiceGraphPolicy`, constraining internal token issuance to declared + edges), +- gateway profile TOML per gateway (loads into + `ras-authorization-gateway`'s `GatewayConfig::from_profile_toml`, with + upstream bindings supplied by the deployment), +- Mermaid diagrams. + +Validation covers unique service ids/audiences, gateway route conflicts +(per-profile; cross-profile conflicts allowed), undeclared references, +public-gateway exposure of private services (explicit `expose_private` +required), and edge permissions checked against the target service's +manifest (explicit `call_with_custom_permissions` escape hatch). + +Artifacts are byte-deterministic with stable content-derived ids, so they +diff cleanly in CI and serve as audit input. Deployment concerns (DNS, +schedulers, ingress, upstream URLs) deliberately stay out of the model. + +Use `ras-topology-macro`'s `ras_topology!` for the declarative form with +typed references to manifest functions and permission constants. diff --git a/crates/topology/ras-topology-core/src/artifacts.rs b/crates/topology/ras-topology-core/src/artifacts.rs new file mode 100644 index 0000000..c238398 --- /dev/null +++ b/crates/topology/ras-topology-core/src/artifacts.rs @@ -0,0 +1,182 @@ +//! Deterministic artifact emission: authorization policy, gateway profiles, +//! and diagrams. +//! +//! Every artifact carries the schema version, the topology name, and a +//! deterministic content-derived id, so CI diffs are meaningful and +//! authorization decisions can be traced to the exact topology revision +//! that produced them. + +use serde::Serialize; +use sha2::{Digest, Sha256}; + +use crate::error::TopologyError; +use crate::model::{Exposure, Topology}; + +/// Artifact schema version. +pub const ARTIFACT_SCHEMA_VERSION: u32 = 1; + +#[derive(Serialize)] +struct PolicyArtifact<'a> { + schema_version: u32, + topology_name: &'a str, + policy_id: String, + edges: Vec, +} + +#[derive(Serialize)] +struct PolicyEdge { + caller_service_id: String, + target_audience: String, + permissions: Vec, +} + +#[derive(Serialize)] +struct ProfileArtifact<'a> { + schema_version: u32, + topology: &'a str, + profile: &'a str, + profile_id: String, + routes: std::collections::BTreeMap, +} + +#[derive(Serialize)] +struct ProfileRouteArtifact { + audience: String, + authenticated_only: bool, +} + +/// Short deterministic content hash for stable artifact ids. +fn content_id(payload: &str) -> String { + let digest = Sha256::digest(payload.as_bytes()); + digest + .iter() + .take(8) + .map(|byte| format!("{byte:02x}")) + .collect() +} + +impl Topology { + /// The authorization policy artifact: allowed service-graph edges with + /// their permission ceilings, as pretty JSON. + /// + /// Schema-compatible with `ras-authorization-core`'s + /// `ServiceGraphPolicy`, so the authority can load it directly and + /// refuse token issuance outside the declared graph. + pub fn authz_policy_json(&self) -> Result { + let mut edges: Vec = self + .calls() + .iter() + .map(|call| { + let target = self + .service(&call.target_id) + .expect("validated at build time"); + PolicyEdge { + caller_service_id: call.caller_id.clone(), + target_audience: target.audience.clone(), + permissions: call.permissions.iter().cloned().collect(), + } + }) + .collect(); + edges.sort_by(|left, right| { + (&left.caller_service_id, &left.target_audience) + .cmp(&(&right.caller_service_id, &right.target_audience)) + }); + + // The id is derived from the edge content itself, so identical + // topologies produce identical artifacts byte-for-byte. + let content = serde_json::to_string(&edges) + .map_err(|err| TopologyError::Invalid(format!("policy serialization: {err}")))?; + let artifact = PolicyArtifact { + schema_version: ARTIFACT_SCHEMA_VERSION, + topology_name: self.name(), + policy_id: format!("{}@{}", self.name(), content_id(&content)), + edges, + }; + serde_json::to_string_pretty(&artifact) + .map_err(|err| TopologyError::Invalid(format!("policy serialization: {err}"))) + } + + /// The gateway profile artifact for one declared gateway, as TOML. + /// + /// Schema-compatible with `ras-authorization-gateway`'s + /// `GatewayProfile`; deployment-specific upstream bindings are + /// deliberately absent. + pub fn gateway_profile_toml(&self, gateway_id: &str) -> Result { + let gateway = self + .gateways() + .iter() + .find(|gateway| gateway.id == gateway_id) + .ok_or_else(|| TopologyError::UnknownGateway(gateway_id.to_string()))?; + + let routes: std::collections::BTreeMap = gateway + .routes + .iter() + .map(|route| { + let target = self + .service(&route.service_id) + .expect("validated at build time"); + ( + route.prefix.clone(), + ProfileRouteArtifact { + audience: target.audience.clone(), + authenticated_only: route.authenticated_only, + }, + ) + }) + .collect(); + + let content = serde_json::to_string(&routes) + .map_err(|err| TopologyError::Invalid(format!("profile serialization: {err}")))?; + let artifact = ProfileArtifact { + schema_version: ARTIFACT_SCHEMA_VERSION, + topology: self.name(), + profile: &gateway.id, + profile_id: format!("{}/{}@{}", self.name(), gateway.id, content_id(&content)), + routes, + }; + toml::to_string_pretty(&artifact) + .map_err(|err| TopologyError::Invalid(format!("profile serialization: {err}"))) + } + + /// A Mermaid flowchart of the topology: gateways, services, routes, and + /// call edges with their permissions. Contains no secrets by + /// construction (the model holds none). + pub fn mermaid(&self) -> String { + let mut out = String::from("flowchart LR\n"); + for gateway in self.gateways() { + let shape = match gateway.exposure { + Exposure::Public => { + format!("{}([\"{} (public gateway)\"])", gateway.id, gateway.id) + } + Exposure::Private => { + format!("{}([\"{} (private gateway)\"])", gateway.id, gateway.id) + } + }; + out.push_str(&format!(" {shape}\n")); + } + for service in self.services() { + out.push_str(&format!( + " {}[\"{} ({})\"]\n", + service.id, service.id, service.audience + )); + } + for gateway in self.gateways() { + for route in &gateway.routes { + out.push_str(&format!( + " {} -->|{}| {}\n", + gateway.id, route.prefix, route.service_id + )); + } + } + for call in self.calls() { + let permissions: Vec<&str> = call.permissions.iter().map(String::as_str).collect(); + out.push_str(&format!( + " {} -.->|{}| {}\n", + call.caller_id, + permissions.join(", "), + call.target_id + )); + } + out + } +} diff --git a/crates/topology/ras-topology-core/src/error.rs b/crates/topology/ras-topology-core/src/error.rs new file mode 100644 index 0000000..5a7c0a0 --- /dev/null +++ b/crates/topology/ras-topology-core/src/error.rs @@ -0,0 +1,63 @@ +//! Topology validation errors. + +use thiserror::Error; + +/// Validation failures raised by [`crate::TopologyBuilder::build`]. +/// +/// All topology validation is deterministic build/test-time validation: +/// run `build()` in a test (or build script) and the graph is checked on +/// every compile of that test target. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum TopologyError { + #[error("duplicate service id {0:?}")] + DuplicateServiceId(String), + + #[error("duplicate audience {audience:?} (services {first:?} and {second:?})")] + DuplicateAudience { + audience: String, + first: String, + second: String, + }, + + #[error("duplicate gateway id {0:?}")] + DuplicateGatewayId(String), + + #[error("gateway {gateway:?} declares duplicate route prefix {prefix:?}")] + DuplicateRoutePrefix { gateway: String, prefix: String }, + + #[error("gateway {gateway:?} route {prefix:?} targets undeclared service {service:?}")] + UnknownRouteTarget { + gateway: String, + prefix: String, + service: String, + }, + + #[error( + "public gateway {gateway:?} exposes private service {service:?} via {prefix:?}; \ + mark the route expose_private to allow this deliberately" + )] + PublicGatewayExposesPrivateService { + gateway: String, + prefix: String, + service: String, + }, + + #[error("call edge references undeclared service {0:?}")] + UnknownCallService(String), + + #[error( + "call {caller:?} -> {target:?} uses permission {permission:?} which is not in \ + {target:?}'s imported manifest; use call_with_custom_permissions if intentional" + )] + PermissionNotInTargetManifest { + caller: String, + target: String, + permission: String, + }, + + #[error("unknown gateway {0:?}")] + UnknownGateway(String), + + #[error("invalid topology: {0}")] + Invalid(String), +} diff --git a/crates/topology/ras-topology-core/src/lib.rs b/crates/topology/ras-topology-core/src/lib.rs new file mode 100644 index 0000000..892169a --- /dev/null +++ b/crates/topology/ras-topology-core/src/lib.rs @@ -0,0 +1,46 @@ +//! Deployment-agnostic RAS service topology (issue #15). +//! +//! Declare the *logical* service graph — services with audiences and +//! generated permission manifests, gateway profiles with routes, and +//! allowed service-to-service call edges — then validate it +//! deterministically and emit the artifacts the rest of the stack consumes: +//! +//! - **Authorization policy** ([`Topology::authz_policy_json`]): +//! schema-compatible with `ras-authorization-core`'s `ServiceGraphPolicy`, +//! so the authority refuses to mint service tokens outside the declared +//! graph. +//! - **Gateway profiles** ([`Topology::gateway_profile_toml`]): +//! schema-compatible with `ras-authorization-gateway`'s `GatewayProfile`; +//! deployment-specific upstream bindings stay external. +//! - **Diagrams** ([`Topology::mermaid`]). +//! +//! Validation runs in [`TopologyBuilder::build`] and covers: unique service +//! ids and audiences, unique gateway ids, per-gateway route-prefix +//! uniqueness (cross-gateway conflicts are allowed), routes and call edges +//! referencing declared services, public-gateway exposure of private +//! services failing unless explicitly allowed, and edge permissions checked +//! against the *target* service's imported manifest (with an explicit +//! `call_with_custom_permissions` escape hatch). +//! +//! Artifacts are deterministic — identical topologies produce byte-identical +//! output with stable content-derived ids — so they diff cleanly in CI and +//! are auditable. +//! +//! The topology never owns deployment concerns: schedulers, DNS, ingress, +//! meshes, and upstream URLs are provided by the deployment when artifacts +//! are loaded. +//! +//! The `ras-topology-macro` crate provides the `ras_topology!` macro that +//! generates builder code from a declarative graph description, with typed +//! references to manifest functions and generated permission constants. + +mod artifacts; +mod error; +mod model; + +pub use artifacts::ARTIFACT_SCHEMA_VERSION; +pub use error::TopologyError; +pub use model::{ + CallEdge, Exposure, GatewayNode, IntoManifest, RouteDecl, ServiceNode, Topology, + TopologyBuilder, +}; diff --git a/crates/topology/ras-topology-core/src/model.rs b/crates/topology/ras-topology-core/src/model.rs new file mode 100644 index 0000000..bedbcf3 --- /dev/null +++ b/crates/topology/ras-topology-core/src/model.rs @@ -0,0 +1,311 @@ +//! The topology model and its build-time validation. + +use std::collections::{BTreeMap, BTreeSet}; + +use ras_permission_manifest::{PermissionManifest, ServicePermissions}; + +use crate::error::TopologyError; + +/// Anything usable as a service's permission manifest in a topology: +/// either a combined [`PermissionManifest`] or a single generated +/// [`ServicePermissions`] (what `generate_*_permission_manifest` functions +/// return). +pub trait IntoManifest { + fn into_manifest(self) -> PermissionManifest; +} + +impl IntoManifest for PermissionManifest { + fn into_manifest(self) -> PermissionManifest { + self + } +} + +impl IntoManifest for ServicePermissions { + fn into_manifest(self) -> PermissionManifest { + PermissionManifest::from_services([self]) + } +} + +/// Whether a node may be reached from outside the deployment. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Exposure { + Public, + Private, +} + +/// A declared service: a logical node with an audience and its generated +/// permission manifest. +#[derive(Debug, Clone)] +pub struct ServiceNode { + pub id: String, + pub audience: String, + pub exposure: Exposure, + /// The service's generated permission manifest; edge permissions are + /// validated against it. + pub manifest: PermissionManifest, +} + +/// One gateway route: path prefix → declared service. +#[derive(Debug, Clone)] +pub struct RouteDecl { + pub prefix: String, + pub service_id: String, + /// Allow requests with no permissions for the audience (forwarded to + /// generated gateway profiles). + pub authenticated_only: bool, + /// Explicitly allow a public gateway to expose a private service. + pub expose_private: bool, +} + +impl RouteDecl { + pub fn new(prefix: impl Into, service_id: impl Into) -> Self { + Self { + prefix: prefix.into(), + service_id: service_id.into(), + authenticated_only: false, + expose_private: false, + } + } + + pub fn authenticated_only(mut self) -> Self { + self.authenticated_only = true; + self + } + + pub fn expose_private(mut self) -> Self { + self.expose_private = true; + self + } +} + +/// A declared gateway profile. +#[derive(Debug, Clone)] +pub struct GatewayNode { + pub id: String, + pub exposure: Exposure, + pub routes: Vec, +} + +/// A declared service-to-service call edge. +#[derive(Debug, Clone)] +pub struct CallEdge { + pub caller_id: String, + pub target_id: String, + pub permissions: BTreeSet, + /// Whether the permissions were declared through the explicit custom + /// path (skipping manifest validation). + pub custom: bool, +} + +/// A validated topology. Construct through [`Topology::builder`]. +#[derive(Debug, Clone)] +pub struct Topology { + pub(crate) name: String, + pub(crate) services: Vec, + pub(crate) gateways: Vec, + pub(crate) calls: Vec, +} + +impl Topology { + pub fn builder(name: impl Into) -> TopologyBuilder { + TopologyBuilder { + name: name.into(), + services: Vec::new(), + gateways: Vec::new(), + calls: Vec::new(), + } + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn services(&self) -> &[ServiceNode] { + &self.services + } + + pub fn gateways(&self) -> &[GatewayNode] { + &self.gateways + } + + pub fn calls(&self) -> &[CallEdge] { + &self.calls + } + + pub(crate) fn service(&self, id: &str) -> Option<&ServiceNode> { + self.services.iter().find(|service| service.id == id) + } +} + +/// Builder with deterministic build-time validation. +pub struct TopologyBuilder { + name: String, + services: Vec, + gateways: Vec, + calls: Vec, +} + +impl TopologyBuilder { + /// Declare a service with its audience, exposure, and generated + /// permission manifest. + pub fn service( + mut self, + id: impl Into, + audience: impl Into, + exposure: Exposure, + manifest: impl IntoManifest, + ) -> Self { + self.services.push(ServiceNode { + id: id.into(), + audience: audience.into(), + exposure, + manifest: manifest.into_manifest(), + }); + self + } + + /// Declare a gateway profile with its routes. + pub fn gateway( + mut self, + id: impl Into, + exposure: Exposure, + routes: Vec, + ) -> Self { + self.gateways.push(GatewayNode { + id: id.into(), + exposure, + routes, + }); + self + } + + /// Declare an allowed service-to-service call edge. Permissions are + /// validated against the target service's manifest at build time. + pub fn call( + mut self, + caller_id: impl Into, + target_id: impl Into, + permissions: impl IntoIterator>, + ) -> Self { + self.calls.push(CallEdge { + caller_id: caller_id.into(), + target_id: target_id.into(), + permissions: permissions.into_iter().map(Into::into).collect(), + custom: false, + }); + self + } + + /// Declare a call edge whose permissions are *not* validated against + /// the target manifest. Explicitly named so manual permission strings + /// stay visible. + pub fn call_with_custom_permissions( + mut self, + caller_id: impl Into, + target_id: impl Into, + permissions: impl IntoIterator>, + ) -> Self { + self.calls.push(CallEdge { + caller_id: caller_id.into(), + target_id: target_id.into(), + permissions: permissions.into_iter().map(Into::into).collect(), + custom: true, + }); + self + } + + /// Validate the graph and produce a [`Topology`]. + pub fn build(self) -> Result { + if self.name.is_empty() { + return Err(TopologyError::Invalid( + "topology name must not be empty".to_string(), + )); + } + + // Unique service ids and audiences. + let mut audiences: BTreeMap<&str, &str> = BTreeMap::new(); + let mut service_ids: BTreeSet<&str> = BTreeSet::new(); + for service in &self.services { + if !service_ids.insert(&service.id) { + return Err(TopologyError::DuplicateServiceId(service.id.clone())); + } + if let Some(first) = audiences.insert(&service.audience, &service.id) { + return Err(TopologyError::DuplicateAudience { + audience: service.audience.clone(), + first: first.to_string(), + second: service.id.clone(), + }); + } + } + + // Gateways: unique ids, per-gateway unique prefixes, declared + // targets, exposure rules. + let mut gateway_ids: BTreeSet<&str> = BTreeSet::new(); + for gateway in &self.gateways { + if !gateway_ids.insert(&gateway.id) { + return Err(TopologyError::DuplicateGatewayId(gateway.id.clone())); + } + let mut prefixes: BTreeSet<&str> = BTreeSet::new(); + for route in &gateway.routes { + if !prefixes.insert(&route.prefix) { + return Err(TopologyError::DuplicateRoutePrefix { + gateway: gateway.id.clone(), + prefix: route.prefix.clone(), + }); + } + let target = self + .services + .iter() + .find(|service| service.id == route.service_id) + .ok_or_else(|| TopologyError::UnknownRouteTarget { + gateway: gateway.id.clone(), + prefix: route.prefix.clone(), + service: route.service_id.clone(), + })?; + if gateway.exposure == Exposure::Public + && target.exposure == Exposure::Private + && !route.expose_private + { + return Err(TopologyError::PublicGatewayExposesPrivateService { + gateway: gateway.id.clone(), + prefix: route.prefix.clone(), + service: target.id.clone(), + }); + } + } + } + + // Calls: declared endpoints, manifest-known permissions. + for call in &self.calls { + for endpoint in [&call.caller_id, &call.target_id] { + if !service_ids.contains(endpoint.as_str()) { + return Err(TopologyError::UnknownCallService(endpoint.clone())); + } + } + if !call.custom { + let target = self + .services + .iter() + .find(|service| service.id == call.target_id) + .expect("target existence checked above"); + let known = target.manifest.permissions(); + for permission in &call.permissions { + if !known.contains(permission.as_str()) { + return Err(TopologyError::PermissionNotInTargetManifest { + caller: call.caller_id.clone(), + target: call.target_id.clone(), + permission: permission.clone(), + }); + } + } + } + } + + Ok(Topology { + name: self.name, + services: self.services, + gateways: self.gateways, + calls: self.calls, + }) + } +} diff --git a/crates/topology/ras-topology-core/tests/macro_tests.rs b/crates/topology/ras-topology-core/tests/macro_tests.rs new file mode 100644 index 0000000..bc0f289 --- /dev/null +++ b/crates/topology/ras-topology-core/tests/macro_tests.rs @@ -0,0 +1,120 @@ +//! `ras_topology!` macro tests: the generated function builds a validated +//! topology with typed references to manifest functions and permission +//! constants. + +use ras_topology_macro::ras_topology; + +/// Stand-in for a generated service API crate. +mod invoice_api { + use ras_permission_manifest::{ + AuthRequirementInfo, OperationKind, OperationPermissions, PermissionManifest, + PermissionRef, ServicePermissions, TransportKind, WireTarget, + }; + + pub mod invoiceservice_permissions { + use super::PermissionRef; + pub const INVOICE_READ: PermissionRef = PermissionRef::new("invoice:read"); + pub const INVOICE_WRITE: PermissionRef = PermissionRef::new("invoice:write"); + } + + pub fn generate_invoiceservice_permission_manifest() -> PermissionManifest { + PermissionManifest::from_services([ServicePermissions { + service_name: "InvoiceService".to_string(), + transport: TransportKind::Rest, + operations: vec![OperationPermissions { + operation_id: "list_invoices".to_string(), + operation_name: "list_invoices".to_string(), + kind: OperationKind::RestEndpoint, + wire: WireTarget::Rest { + method: "GET".to_string(), + path: "/invoices".to_string(), + }, + auth: AuthRequirementInfo::from_permission_groups([[ + invoiceservice_permissions::INVOICE_READ.as_str(), + invoiceservice_permissions::INVOICE_WRITE.as_str(), + ]]), + version: None, + canonical_operation_id: None, + }], + }]) + } +} + +mod billing_api { + use ras_permission_manifest::{PermissionManifest, ServicePermissions, TransportKind}; + + pub fn generate_billingservice_permission_manifest() -> PermissionManifest { + PermissionManifest::from_services([ServicePermissions { + service_name: "BillingService".to_string(), + transport: TransportKind::Rest, + operations: vec![], + }]) + } +} + +ras_topology!({ + topology_name: InternalTools, + + services: [ + invoice: { + audience: "invoice-service", + manifest: invoice_api::generate_invoiceservice_permission_manifest, + exposure: private, + }, + billing: { + audience: "billing-service", + manifest: billing_api::generate_billingservice_permission_manifest, + exposure: private, + }, + ], + + gateways: [ + public_web: { + exposure: public, + routes: [ + "/invoices" => invoice { expose_private }, + "/billing" => billing { expose_private, authenticated_only }, + ], + }, + internal_admin: { + exposure: private, + routes: [ + "/invoices" => invoice, + ], + }, + ], + + calls: [ + billing -> invoice { + permissions: [ + invoice_api::invoiceservice_permissions::INVOICE_READ, + invoice_api::invoiceservice_permissions::INVOICE_WRITE, + ], + }, + ], +}); + +#[test] +fn generated_function_builds_a_validated_topology() { + let topology = internal_tools_topology().unwrap(); + assert_eq!(topology.name(), "InternalTools"); + assert_eq!(topology.services().len(), 2); + assert_eq!(topology.gateways().len(), 2); + assert_eq!(topology.calls().len(), 1); + + // Typed permission constants flowed into the edge. + let policy_json = topology.authz_policy_json().unwrap(); + let policy: ras_authorization_core::ServiceGraphPolicy = + serde_json::from_str(&policy_json).unwrap(); + let edge = policy.edge("billing", "invoice-service").unwrap(); + assert!(edge.permissions.contains("invoice:read")); + assert!(edge.permissions.contains("invoice:write")); + + // Both gateway profiles emit independently. + assert!(topology.gateway_profile_toml("public_web").is_ok()); + assert!(topology.gateway_profile_toml("internal_admin").is_ok()); + + // Route flags survived: billing route is authenticated-only. + let toml = topology.gateway_profile_toml("public_web").unwrap(); + assert!(toml.contains("authenticated_only = true")); +} diff --git a/crates/topology/ras-topology-core/tests/topology_tests.rs b/crates/topology/ras-topology-core/tests/topology_tests.rs new file mode 100644 index 0000000..d402fcb --- /dev/null +++ b/crates/topology/ras-topology-core/tests/topology_tests.rs @@ -0,0 +1,322 @@ +//! Topology validation, deterministic artifacts, and consumption by the +//! authorization control plane and the gateway. + +use std::collections::BTreeMap; + +use ras_permission_manifest::{ + AuthRequirementInfo, OperationKind, OperationPermissions, PermissionManifest, + ServicePermissions, TransportKind, WireTarget, +}; +use ras_topology_core::{Exposure, RouteDecl, Topology, TopologyError}; + +fn manifest(service: &str, permissions: &[&str]) -> PermissionManifest { + let operations = permissions + .iter() + .map(|permission| OperationPermissions { + operation_id: format!("op_{permission}"), + operation_name: format!("op_{permission}"), + kind: OperationKind::RestEndpoint, + wire: WireTarget::Rest { + method: "GET".to_string(), + path: format!("/{permission}"), + }, + auth: AuthRequirementInfo::from_permission_groups([[*permission]]), + version: None, + canonical_operation_id: None, + }) + .collect(); + PermissionManifest::from_services([ServicePermissions { + service_name: service.to_string(), + transport: TransportKind::Rest, + operations, + }]) +} + +fn base_builder() -> ras_topology_core::TopologyBuilder { + Topology::builder("InternalTools") + .service( + "invoice", + "invoice-service", + Exposure::Private, + manifest("InvoiceService", &["invoice:read", "invoice:write"]), + ) + .service( + "billing", + "billing-service", + Exposure::Private, + manifest("BillingService", &["billing:read"]), + ) +} + +#[test] +fn valid_topology_builds() { + let topology = base_builder() + .gateway( + "public_web", + Exposure::Public, + vec![ + RouteDecl::new("/invoices", "invoice").expose_private(), + RouteDecl::new("/billing", "billing").expose_private(), + ], + ) + .call("billing", "invoice", ["invoice:read"]) + .build() + .unwrap(); + assert_eq!(topology.name(), "InternalTools"); + assert_eq!(topology.services().len(), 2); +} + +#[test] +fn duplicate_ids_and_audiences_are_rejected() { + let err = base_builder() + .service( + "invoice", + "other-audience", + Exposure::Private, + manifest("X", &[]), + ) + .build() + .unwrap_err(); + assert_eq!( + err, + TopologyError::DuplicateServiceId("invoice".to_string()) + ); + + let err = base_builder() + .service( + "invoice2", + "invoice-service", + Exposure::Private, + manifest("X", &[]), + ) + .build() + .unwrap_err(); + assert!(matches!(err, TopologyError::DuplicateAudience { .. })); +} + +#[test] +fn gateway_validation_catches_conflicts_unknown_targets_and_exposure() { + // Duplicate prefix within one gateway. + let err = base_builder() + .gateway( + "gw", + Exposure::Private, + vec![ + RouteDecl::new("/x", "invoice"), + RouteDecl::new("/x", "billing"), + ], + ) + .build() + .unwrap_err(); + assert!(matches!(err, TopologyError::DuplicateRoutePrefix { .. })); + + // The same prefix on *different* gateways is fine. + base_builder() + .gateway( + "gw1", + Exposure::Private, + vec![RouteDecl::new("/x", "invoice")], + ) + .gateway( + "gw2", + Exposure::Private, + vec![RouteDecl::new("/x", "billing")], + ) + .build() + .unwrap(); + + // Unknown route target. + let err = base_builder() + .gateway("gw", Exposure::Private, vec![RouteDecl::new("/x", "ghost")]) + .build() + .unwrap_err(); + assert!(matches!(err, TopologyError::UnknownRouteTarget { .. })); + + // Public gateway exposing a private service fails by default... + let err = base_builder() + .gateway( + "public_web", + Exposure::Public, + vec![RouteDecl::new("/invoices", "invoice")], + ) + .build() + .unwrap_err(); + assert!(matches!( + err, + TopologyError::PublicGatewayExposesPrivateService { .. } + )); + + // ...unless explicitly allowed. + base_builder() + .gateway( + "public_web", + Exposure::Public, + vec![RouteDecl::new("/invoices", "invoice").expose_private()], + ) + .build() + .unwrap(); +} + +#[test] +fn call_edges_are_validated_against_target_manifests() { + // Unknown endpoint. + let err = base_builder() + .call("ghost", "invoice", ["invoice:read"]) + .build() + .unwrap_err(); + assert_eq!(err, TopologyError::UnknownCallService("ghost".to_string())); + + // Permission not in the target manifest (billing:read belongs to the + // *caller*, not the target — audience scoping in action). + let err = base_builder() + .call("billing", "invoice", ["billing:read"]) + .build() + .unwrap_err(); + assert!(matches!( + err, + TopologyError::PermissionNotInTargetManifest { permission, .. } if permission == "billing:read" + )); + + // The explicit custom path skips manifest validation. + base_builder() + .call_with_custom_permissions("billing", "invoice", ["legacy:perm"]) + .build() + .unwrap(); +} + +#[test] +fn artifacts_are_deterministic() { + let build = || { + base_builder() + .gateway( + "public_web", + Exposure::Public, + vec![RouteDecl::new("/invoices", "invoice").expose_private()], + ) + .call("billing", "invoice", ["invoice:read", "invoice:write"]) + .build() + .unwrap() + }; + let first = build(); + let second = build(); + assert_eq!( + first.authz_policy_json().unwrap(), + second.authz_policy_json().unwrap() + ); + assert_eq!( + first.gateway_profile_toml("public_web").unwrap(), + second.gateway_profile_toml("public_web").unwrap() + ); + assert_eq!(first.mermaid(), second.mermaid()); + + // Changing the topology changes the artifact ids. + let other = base_builder() + .gateway( + "public_web", + Exposure::Public, + vec![RouteDecl::new("/invoices", "invoice").expose_private()], + ) + .call("billing", "invoice", ["invoice:read"]) + .build() + .unwrap(); + assert_ne!( + first.authz_policy_json().unwrap(), + other.authz_policy_json().unwrap() + ); +} + +#[test] +fn authz_policy_artifact_loads_into_the_control_plane() { + let topology = base_builder() + .call("billing", "invoice", ["invoice:read"]) + .build() + .unwrap(); + let json = topology.authz_policy_json().unwrap(); + + // The artifact is schema-compatible with the authority's policy type. + let policy: ras_authorization_core::ServiceGraphPolicy = serde_json::from_str(&json).unwrap(); + assert_eq!(policy.topology_name, "InternalTools"); + assert!(policy.policy_id.starts_with("InternalTools@")); + let edge = policy.edge("billing", "invoice-service").unwrap(); + assert!(edge.permissions.contains("invoice:read")); + assert!(policy.edge("invoice", "billing-service").is_none()); +} + +#[test] +fn gateway_profile_artifact_loads_into_the_gateway() { + let topology = base_builder() + .gateway( + "public_web", + Exposure::Public, + vec![ + RouteDecl::new("/invoices", "invoice").expose_private(), + RouteDecl::new("/billing", "billing") + .expose_private() + .authenticated_only(), + ], + ) + .build() + .unwrap(); + let toml = topology.gateway_profile_toml("public_web").unwrap(); + + // Missing upstream binding fails startup validation. + let mut upstreams = BTreeMap::new(); + upstreams.insert( + "invoice-service".to_string(), + "http://invoice:3000".to_string(), + ); + assert!( + ras_authorization_gateway::GatewayConfig::from_profile_toml( + "https://auth.internal", + "https://gateway.internal", + &toml, + &upstreams, + ) + .is_err() + ); + + // Complete bindings load. + upstreams.insert( + "billing-service".to_string(), + "http://billing:3000".to_string(), + ); + let config = ras_authorization_gateway::GatewayConfig::from_profile_toml( + "https://auth.internal", + "https://gateway.internal", + &toml, + &upstreams, + ) + .unwrap(); + assert_eq!(config.routes.len(), 2); + let billing = config + .routes + .iter() + .find(|route| route.audience == "billing-service") + .unwrap(); + assert!(billing.authenticated_only); + + // Unknown gateway id fails. + assert!(matches!( + topology.gateway_profile_toml("nope").unwrap_err(), + TopologyError::UnknownGateway(_) + )); +} + +#[test] +fn mermaid_diagram_contains_nodes_routes_and_edges() { + let topology = base_builder() + .gateway( + "public_web", + Exposure::Public, + vec![RouteDecl::new("/invoices", "invoice").expose_private()], + ) + .call("billing", "invoice", ["invoice:read"]) + .build() + .unwrap(); + let mermaid = topology.mermaid(); + assert!(mermaid.starts_with("flowchart LR")); + assert!(mermaid.contains("public_web")); + assert!(mermaid.contains("invoice-service")); + assert!(mermaid.contains("-->|/invoices| invoice")); + assert!(mermaid.contains("billing -.->|invoice:read| invoice")); +} diff --git a/crates/topology/ras-topology-macro/Cargo.toml b/crates/topology/ras-topology-macro/Cargo.toml new file mode 100644 index 0000000..7f617bc --- /dev/null +++ b/crates/topology/ras-topology-macro/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "ras-topology-macro" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +description = "ras_topology! macro: declare compile-checked RAS service graphs that generate validated topology builders" +keywords = ["api", "topology", "macro", "authorization", "codegen"] +categories = ["development-tools", "web-programming"] +license = "MIT OR Apache-2.0" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" +readme = "README.md" + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = { workspace = true } +quote = { workspace = true } +syn = { workspace = true } diff --git a/crates/topology/ras-topology-macro/README.md b/crates/topology/ras-topology-macro/README.md new file mode 100644 index 0000000..99a5460 --- /dev/null +++ b/crates/topology/ras-topology-macro/README.md @@ -0,0 +1,14 @@ +# ras-topology-macro + +The `ras_topology!` macro: declare a RAS service graph and generate a +function returning a validated `ras_topology_core::Topology`. + +Compile-time checks: duplicate service/gateway ids and routes or call edges +referencing undeclared services fail the build with spanned errors; manifest +functions and permission constants are referenced by path, so renames and +removals in service API crates break the topology build immediately. +Value-dependent validation (audience uniqueness, manifest membership of edge +permissions, exposure rules) runs deterministically in the generated +`build()` call. + +See the crate docs for the full syntax. diff --git a/crates/topology/ras-topology-macro/src/lib.rs b/crates/topology/ras-topology-macro/src/lib.rs new file mode 100644 index 0000000..6ef09b0 --- /dev/null +++ b/crates/topology/ras-topology-macro/src/lib.rs @@ -0,0 +1,495 @@ +//! The `ras_topology!` macro. +//! +//! Declares a logical RAS service graph and generates a function returning a +//! validated [`Topology`](https://docs.rs/ras-topology-core). Compile-time +//! guarantees come from two places: +//! +//! - The macro itself rejects duplicate service/gateway ids and routes or +//! call edges that reference undeclared services. +//! - The generated code references manifest functions and permission +//! constants *by path*, so renamed or removed services and permissions +//! fail the build of the topology crate. +//! +//! Everything value-dependent (audience uniqueness, manifest membership of +//! edge permissions, exposure rules) is validated deterministically by +//! `TopologyBuilder::build` inside the generated function. +//! +//! # Syntax +//! +//! ```ignore +//! ras_topology!({ +//! topology_name: InternalTools, +//! +//! services: [ +//! invoice: { +//! audience: "invoice-service", +//! manifest: invoice_api::generate_invoiceservice_permission_manifest, +//! exposure: private, +//! }, +//! billing: { +//! audience: "billing-service", +//! manifest: billing_api::generate_billingservice_permission_manifest, +//! exposure: private, +//! }, +//! ], +//! +//! gateways: [ +//! public_web: { +//! exposure: public, +//! routes: [ +//! "/invoices" => invoice { expose_private }, +//! "/billing" => billing { expose_private, authenticated_only }, +//! ], +//! }, +//! ], +//! +//! calls: [ +//! billing -> invoice { +//! permissions: [ +//! invoice_api::invoiceservice_permissions::INVOICE_READ, +//! ], +//! }, +//! ], +//! }); +//! ``` +//! +//! This generates `pub fn internal_tools_topology() -> Result`. Permission entries must be generated +//! `PermissionRef` constants; raw strings go through the explicit +//! `custom_permissions: ["..."]` list instead. + +use proc_macro::TokenStream; +use quote::{format_ident, quote}; +use syn::parse::{Parse, ParseStream}; +use syn::punctuated::Punctuated; +use syn::{Ident, LitStr, Path, Token, braced, bracketed}; + +struct TopologyInput { + name: Ident, + services: Vec, + gateways: Vec, + calls: Vec, +} + +struct ServiceDecl { + id: Ident, + audience: LitStr, + manifest: Path, + exposure: Exposure, +} + +struct GatewayDecl { + id: Ident, + exposure: Exposure, + routes: Vec, +} + +struct RouteDecl { + prefix: LitStr, + target: Ident, + expose_private: bool, + authenticated_only: bool, +} + +struct CallDecl { + caller: Ident, + target: Ident, + permissions: Vec, + custom_permissions: Vec, +} + +enum Exposure { + Public, + Private, +} + +fn parse_exposure(input: ParseStream) -> syn::Result { + let ident: Ident = input.parse()?; + match ident.to_string().as_str() { + "public" => Ok(Exposure::Public), + "private" => Ok(Exposure::Private), + other => Err(syn::Error::new_spanned( + &ident, + format!("exposure must be `public` or `private`, got `{other}`"), + )), + } +} + +impl Parse for ServiceDecl { + fn parse(input: ParseStream) -> syn::Result { + let id: Ident = input.parse()?; + input.parse::()?; + let body; + braced!(body in input); + + let mut audience = None; + let mut manifest = None; + let mut exposure = None; + while !body.is_empty() { + let key: Ident = body.parse()?; + body.parse::()?; + match key.to_string().as_str() { + "audience" => audience = Some(body.parse::()?), + "manifest" => manifest = Some(body.parse::()?), + "exposure" => exposure = Some(parse_exposure(&body)?), + other => { + return Err(syn::Error::new_spanned( + &key, + format!("unknown service field `{other}`"), + )); + } + } + if body.peek(Token![,]) { + body.parse::()?; + } + } + + let missing = |field: &str| { + syn::Error::new_spanned(&id, format!("service `{id}` is missing `{field}`")) + }; + Ok(ServiceDecl { + audience: audience.ok_or_else(|| missing("audience"))?, + manifest: manifest.ok_or_else(|| missing("manifest"))?, + exposure: exposure.ok_or_else(|| missing("exposure"))?, + id, + }) + } +} + +impl Parse for RouteDecl { + fn parse(input: ParseStream) -> syn::Result { + let prefix: LitStr = input.parse()?; + input.parse::]>()?; + let target: Ident = input.parse()?; + let mut expose_private = false; + let mut authenticated_only = false; + if input.peek(syn::token::Brace) { + let flags; + braced!(flags in input); + let flags: Punctuated = + flags.parse_terminated(Ident::parse, Token![,])?; + for flag in flags { + match flag.to_string().as_str() { + "expose_private" => expose_private = true, + "authenticated_only" => authenticated_only = true, + other => { + return Err(syn::Error::new_spanned( + &flag, + format!("unknown route flag `{other}`"), + )); + } + } + } + } + Ok(RouteDecl { + prefix, + target, + expose_private, + authenticated_only, + }) + } +} + +impl Parse for GatewayDecl { + fn parse(input: ParseStream) -> syn::Result { + let id: Ident = input.parse()?; + input.parse::()?; + let body; + braced!(body in input); + + let mut exposure = None; + let mut routes = Vec::new(); + while !body.is_empty() { + let key: Ident = body.parse()?; + body.parse::()?; + match key.to_string().as_str() { + "exposure" => exposure = Some(parse_exposure(&body)?), + "routes" => { + let list; + bracketed!(list in body); + let parsed: Punctuated = + list.parse_terminated(RouteDecl::parse, Token![,])?; + routes = parsed.into_iter().collect(); + } + other => { + return Err(syn::Error::new_spanned( + &key, + format!("unknown gateway field `{other}`"), + )); + } + } + if body.peek(Token![,]) { + body.parse::()?; + } + } + + Ok(GatewayDecl { + exposure: exposure.ok_or_else(|| { + syn::Error::new_spanned(&id, format!("gateway `{id}` is missing `exposure`")) + })?, + id, + routes, + }) + } +} + +impl Parse for CallDecl { + fn parse(input: ParseStream) -> syn::Result { + let caller: Ident = input.parse()?; + input.parse::]>()?; + let target: Ident = input.parse()?; + let body; + braced!(body in input); + + let mut permissions = Vec::new(); + let mut custom_permissions = Vec::new(); + while !body.is_empty() { + let key: Ident = body.parse()?; + body.parse::()?; + match key.to_string().as_str() { + "permissions" => { + let list; + bracketed!(list in body); + let parsed: Punctuated = + list.parse_terminated(Path::parse, Token![,])?; + permissions = parsed.into_iter().collect(); + } + "custom_permissions" => { + let list; + bracketed!(list in body); + let parsed: Punctuated = + list.parse_terminated(|p| p.parse::(), Token![,])?; + custom_permissions = parsed.into_iter().collect(); + } + other => { + return Err(syn::Error::new_spanned( + &key, + format!("unknown call field `{other}`"), + )); + } + } + if body.peek(Token![,]) { + body.parse::()?; + } + } + + Ok(CallDecl { + caller, + target, + permissions, + custom_permissions, + }) + } +} + +impl Parse for TopologyInput { + fn parse(input: ParseStream) -> syn::Result { + let content; + braced!(content in input); + + let mut name = None; + let mut services = Vec::new(); + let mut gateways = Vec::new(); + let mut calls = Vec::new(); + + while !content.is_empty() { + let key: Ident = content.parse()?; + content.parse::()?; + match key.to_string().as_str() { + "topology_name" => name = Some(content.parse::()?), + "services" => { + let list; + bracketed!(list in content); + let parsed: Punctuated = + list.parse_terminated(ServiceDecl::parse, Token![,])?; + services = parsed.into_iter().collect(); + } + "gateways" => { + let list; + bracketed!(list in content); + let parsed: Punctuated = + list.parse_terminated(GatewayDecl::parse, Token![,])?; + gateways = parsed.into_iter().collect(); + } + "calls" => { + let list; + bracketed!(list in content); + let parsed: Punctuated = + list.parse_terminated(CallDecl::parse, Token![,])?; + calls = parsed.into_iter().collect(); + } + other => { + return Err(syn::Error::new_spanned( + &key, + format!("unknown topology field `{other}`"), + )); + } + } + if content.peek(Token![,]) { + content.parse::()?; + } + } + + let name = + name.ok_or_else(|| syn::Error::new(input.span(), "topology requires `topology_name`"))?; + if services.is_empty() { + return Err(syn::Error::new_spanned( + &name, + "topology requires at least one service", + )); + } + + Ok(TopologyInput { + name, + services, + gateways, + calls, + }) + } +} + +/// Compile-time structural checks: duplicate ids and references to +/// undeclared services fail the build with spanned errors. +fn check_structure(input: &TopologyInput) -> syn::Result<()> { + let mut service_ids = std::collections::BTreeSet::new(); + for service in &input.services { + if !service_ids.insert(service.id.to_string()) { + return Err(syn::Error::new_spanned( + &service.id, + format!("duplicate service id `{}`", service.id), + )); + } + } + let mut gateway_ids = std::collections::BTreeSet::new(); + for gateway in &input.gateways { + if !gateway_ids.insert(gateway.id.to_string()) { + return Err(syn::Error::new_spanned( + &gateway.id, + format!("duplicate gateway id `{}`", gateway.id), + )); + } + for route in &gateway.routes { + if !service_ids.contains(&route.target.to_string()) { + return Err(syn::Error::new_spanned( + &route.target, + format!("route targets undeclared service `{}`", route.target), + )); + } + } + } + for call in &input.calls { + for endpoint in [&call.caller, &call.target] { + if !service_ids.contains(&endpoint.to_string()) { + return Err(syn::Error::new_spanned( + endpoint, + format!("call references undeclared service `{endpoint}`"), + )); + } + } + } + Ok(()) +} + +fn snake_case(ident: &Ident) -> String { + let mut out = String::new(); + for (index, ch) in ident.to_string().chars().enumerate() { + if ch.is_uppercase() { + if index > 0 { + out.push('_'); + } + out.extend(ch.to_lowercase()); + } else { + out.push(ch); + } + } + out +} + +/// Declare a RAS service topology. See the crate docs for syntax. +#[proc_macro] +pub fn ras_topology(input: TokenStream) -> TokenStream { + let input = syn::parse_macro_input!(input as TopologyInput); + if let Err(err) = check_structure(&input) { + return err.to_compile_error().into(); + } + + let fn_name = format_ident!("{}_topology", snake_case(&input.name)); + let topology_name = input.name.to_string(); + + let services = input.services.iter().map(|service| { + let id = service.id.to_string(); + let audience = &service.audience; + let manifest = &service.manifest; + let exposure = match service.exposure { + Exposure::Public => quote!(::ras_topology_core::Exposure::Public), + Exposure::Private => quote!(::ras_topology_core::Exposure::Private), + }; + quote! { + .service(#id, #audience, #exposure, #manifest()) + } + }); + + let gateways = input.gateways.iter().map(|gateway| { + let id = gateway.id.to_string(); + let exposure = match gateway.exposure { + Exposure::Public => quote!(::ras_topology_core::Exposure::Public), + Exposure::Private => quote!(::ras_topology_core::Exposure::Private), + }; + let routes = gateway.routes.iter().map(|route| { + let prefix = &route.prefix; + let target = route.target.to_string(); + let mut decl = quote! { + ::ras_topology_core::RouteDecl::new(#prefix, #target) + }; + if route.expose_private { + decl = quote! { #decl.expose_private() }; + } + if route.authenticated_only { + decl = quote! { #decl.authenticated_only() }; + } + decl + }); + quote! { + .gateway(#id, #exposure, vec![#(#routes),*]) + } + }); + + let calls = input.calls.iter().map(|call| { + let caller = call.caller.to_string(); + let target = call.target.to_string(); + let typed = if call.permissions.is_empty() && call.custom_permissions.is_empty() { + // An edge with no permissions is valid (authenticated-only call). + Some(quote! { .call(#caller, #target, ::std::iter::empty::<&str>()) }) + } else if call.permissions.is_empty() { + None + } else { + let permissions = call.permissions.iter().map(|path| quote!((#path).as_str())); + Some(quote! { .call(#caller, #target, [#(#permissions),*]) }) + }; + let custom = if call.custom_permissions.is_empty() { + None + } else { + let permissions = call.custom_permissions.iter(); + Some(quote! { + .call_with_custom_permissions(#caller, #target, [#(#permissions),*]) + }) + }; + quote! { #typed #custom } + }); + + let expanded = quote! { + /// Generated by `ras_topology!`: build and validate the declared + /// topology. + pub fn #fn_name() -> ::std::result::Result< + ::ras_topology_core::Topology, + ::ras_topology_core::TopologyError, + > { + ::ras_topology_core::Topology::builder(#topology_name) + #(#services)* + #(#gateways)* + #(#calls)* + .build() + } + }; + expanded.into() +} diff --git a/documentation/src/SUMMARY.md b/documentation/src/SUMMARY.md index 4eb36f7..9e72f68 100644 --- a/documentation/src/SUMMARY.md +++ b/documentation/src/SUMMARY.md @@ -27,3 +27,10 @@ - [Permission Manifests](permission-manifests.md) - [Identity And Sessions](identity-and-sessions.md) - [Observability](observability.md) + +# Multi-Service Auth + +- [Service-To-Service Auth](service-to-service-auth.md) +- [Outbound Integrations](outbound-integrations.md) +- [The Auth Gateway](auth-gateway.md) +- [Topology](topology.md) diff --git a/documentation/src/auth-gateway.md b/documentation/src/auth-gateway.md new file mode 100644 index 0000000..f09ac7b --- /dev/null +++ b/documentation/src/auth-gateway.md @@ -0,0 +1,77 @@ +# The Auth Gateway + +The gateway (`ras-authorization-gateway`) is the third deployment preset, +for one specific shape: a browser frontend that talks to **multiple** +backend services. Single-service apps should keep using embedded auth and +never deploy it. + +## Why narrow tokens + +Forwarding the full browser session to every backend means every service +sees permissions for unrelated services, must understand multi-audience +permission maps, and leaks cross-service data into logs. Calling the +authority on every request defeats the point of signed tokens. The gateway +solves both: + +```text +browser (ras_web_session cookie or bearer) + -> gateway validates the session locally (JWKS, no authority call) + -> longest-prefix route -> target audience + -> session permissions narrowed to that audience only + -> short-lived single-audience ras_gateway_access token minted (cached) + -> request proxied with only the derived bearer attached +backend validates a simple single-audience token via the gateway JWKS +``` + +## Guarantees + +- The original session token is **never** forwarded; inbound + `Authorization`, `Cookie`, `Host`, and hop-by-hop headers are stripped. +- Derived tokens carry exactly one audience and only that audience's + session permissions — never invented, never widened — and never outlive + the session. +- Route matching is deterministic: longest prefix wins, matches are + segment-aligned (`/api` never matches `/api-private`), duplicate prefixes + fail validation, unmatched paths fail closed (404). +- Sessions without permissions for the target audience fail closed (403) + unless the route is explicitly declared `authenticated_only`. +- The derived-token cache (keyed by session, subject, audience, and + authz version) is an optimization only; losing it costs a local re-sign, + not an auth cycle. +- Request and response bodies stream without buffering. + +## Deployment shape + +Deploy the gateway **behind** your existing ingress/load balancer. It is an +application-layer token exchanger, not a general-purpose ingress — TLS +termination, WAF, and rate limiting stay where they are today. The gateway +is effectively stateless (the cache is local), so replicas scale behind a +normal load balancer. + +```rust,ignore +let gateway = Arc::new(AuthGateway::new( + GatewayConfig::from_profile_toml( // generated by the topology + session_issuer, gateway_issuer, &profile_toml, &upstream_bindings)?, + Arc::new(session_jwks) as Arc, + SigningKey::generate_es256("gateway-2026-06"), +)?); +let app = gateway_router(gateway, Arc::new(ReqwestTransport::new())); +``` + +Hand-written `RouteRule`s work for small setups; generated topology +profiles (see [Topology](topology.md)) are preferred once the route set +grows, with upstream URLs always bound by the deployment at startup +(missing bindings fail startup). + +Backends validate derived tokens with `backend_validation_options(issuer, +audience)` plus the gateway JWKS — or, for generated RAS services, +`RasTokenAuthProvider` from `ras-authorization-core`. + +## v1 limitation: WebSockets + +Connection upgrades fail closed with `501 Not Implemented`. Bidirectional +RAS services (`jsonrpc_bidirectional_service!`) must be reached directly — +not through the gateway — until upgrade-aware proxying with +narrowing-at-upgrade-time is designed. This is deliberate: silently +proxying upgrades without re-checking authorization at upgrade time is how +WebSocket authz bugs happen. diff --git a/documentation/src/outbound-integrations.md b/documentation/src/outbound-integrations.md new file mode 100644 index 0000000..b34c91f --- /dev/null +++ b/documentation/src/outbound-integrations.md @@ -0,0 +1,79 @@ +# Outbound Integrations + +RAS services call other systems: third-party APIs (Google, GitHub, customer +systems) and other internal RAS services. `ras-integration-core` provides +the shared, fail-closed machinery so projects stop hand-rolling token +acquisition, caching, refresh, and bearer attachment. + +## The pieces + +- **`TokenSource`** — pluggable acquisition. Implementations: + `OAuth2TokenSource` (`ras-integration-oauth2`), `RasInternalTokenSource` + (`ras-integration-ras`), `StaticTokenSource` for API keys, and + `testing::FakeTokenSource` for tests. +- **`IntegrationConfig`** — per-integration bounds: allowed scopes, allowed + audiences, and allowed outbound base URLs. Anything outside the bounds + fails before a token source is even consulted. +- **`TokenManager`** — caching and refresh. Cache keys include the token + family, integration, subject (with principal mode), audience, canonical + scopes, and config version, so external OAuth tokens and internal RAS + tokens can never collide. Near-expiry leases refresh early; concurrent + refreshes for the same key are deduplicated; errors are never cached. +- **`AuthorizedHttpClient`** — the capability-scoped client handlers should + receive: bound to one integration, one subject, and a fixed scope set. + Handlers cannot request arbitrary integrations, scopes, audiences, or + subjects (the confused-deputy guard). The outbound URL is validated + against the host allowlist *before* a token is minted, and requests are + never automatically replayed after auth failures. +- **`GrantStore`** — persistence for refresh-token grants. A refresh token + is a stored *grant*: the application provides it (consent flow, admin + seeding, migration); RAS uses it. The store is a security boundary — + implement it over your database/secret manager; the in-memory store + serves tests and dev. +- **`SecretString`** — every token and grant secret is redacted in `Debug` + and has no serde implementations. + +## External OAuth2 providers + +```rust,ignore +let source = Arc::new(OAuth2TokenSource::new( + OAuth2ProviderConfig::new("https://accounts.google.com/o/oauth2/token", client_id)? + .with_client_secret(client_secret), + transport.clone(), // any HttpTransport — fake the provider in tests + grant_store.clone(), +)?); + +let google = AuthorizedHttpClient::for_user( + transport, manager, "google-calendar", user.user_id, ["calendar.readonly"], +); +let events = google.get(calendar_url).await?; +``` + +- **User subjects** use the refresh-token flow. Requested scopes are + subset-checked against the stored grant's consented scopes; broader + requests return a typed `ConsentRequired` error (as does a missing or + revoked grant), never a silent fallback. Rotated refresh tokens are + persisted back to the `GrantStore` before the lease is returned, and a + failed save surfaces as an error. +- **Service subjects** use client credentials, forwarding the requested + `audience` for providers that support it. + +`ConsentFlow` covers the consent side: PKCE (S256) authorization URLs with +opaque, single-use, expiring `state` bound to the initiating user, +integration, redirect URI, scopes, and verifier; callback validation; and +the code exchange that stores the grant. + +## Internal RAS services + +Use `RasInternalTokenSource` (see +[Service-To-Service Auth](service-to-service-auth.md)). The same manager, +bounds, cache, and client machinery applies — only the source differs, and +the token family in the cache key keeps the two worlds apart. + +## Testing + +Everything speaks `HttpTransport`, so provider and downstream fakes run +in-process: script token-endpoint responses, assert the exact form +parameters sent, and exercise your real service code path instead of +mocking it away. `ras-integration-core::testing` ships a scriptable +`FakeTokenSource` with call counting for cache/dedup assertions. diff --git a/documentation/src/service-to-service-auth.md b/documentation/src/service-to-service-auth.md new file mode 100644 index 0000000..1104c3b --- /dev/null +++ b/documentation/src/service-to-service-auth.md @@ -0,0 +1,129 @@ +# Service-To-Service Auth + +External identity providers answer *"who is this human?"*. RAS answers +*"what may this user or service do here?"*. The authorization crates add a +RAS-native control plane so internal services get identities, grants, and +short-lived tokens without registering application clients in Auth0/Entra — +and without every project hand-rolling token plumbing. + +## One token model + +Everything builds on `ras-authorization-token`, which defines a single +claims shape (`RasClaims`) for all RAS token families, distinguished by the +`typ` claim: + +| `typ` | Token | Audience shape | +|---|---|---| +| `ras_web_session` | Browser web session | Multi-audience: permissions grouped per audience in `audience_permissions` | +| `ras_internal_access` | Internal service-to-service token | Single `aud` + flat permission list | +| `ras_gateway_access` | Gateway-derived backend token | Single `aud` + flat permission list | + +Tokens are signed JWTs (ES256 recommended, EdDSA supported, HS256 only for +single-process shared-secret setups) with `kid`-based key rotation and JWKS +publication. `TokenValidator` pins the issuer, audience policy, expected +token types, and an algorithm allowlist (asymmetric-only by default), and +guards against key-type confusion by cross-checking the resolved key's +algorithm against the header. + +Because validation is offline (JWKS), revocation latency is bounded by +token TTL: internal tokens default to 5 minutes, gateway tokens to 2. +Emergency revocation removes a retired key from the ring, immediately +killing everything it signed. + +## The control plane + +`ras-authorization-core` owns application authorization: + +- **Registry**: services are registered with a unique audience. +- **Grants are audience-scoped**: a grant says "principal X may use + permission P *at audience A*". The same permission string on two services + never satisfies each other. Roles bundle audience-scoped permissions and + bind to principals (users, services, service accounts, applications). +- **Manifests are the vocabulary**: import each service's generated + permission manifest; grants of unknown permissions are rejected unless + made through the explicit `grant_custom` path. +- **Identity is pluggable**: services prove themselves through a + `ServiceIdentityVerifier`. The shipped `StaticSecretVerifier` is for + development and simple deployments; production should adapt workload + identity (Kubernetes service-account JWTs, SPIFFE/SPIRE, mTLS) behind the + same trait. +- **Issuance fails closed** at every step: identity, registration, + audience existence, audience-scoped grants, and — when a topology policy + is loaded — declared service-graph edges with permission ceilings. +- **Audit**: registrations, grants, issuance outcomes, and key changes emit + append-only events that never contain secrets or token values. + +## Deployment presets + +Start embedded; scale out only when you need to. + +1. **Embedded (default)** — one axum process hosts the authority routes and + the application: + + ```rust,ignore + let issuer = Arc::new(TokenIssuer::builder(issuer_url, key, store, verifier).build()); + let app = my_service_router.merge(authority_router(issuer)); + // POST /auth/token and GET /auth/jwks.json live next to /api/*. + ``` + +2. **Central authority** — several services validate tokens from one shared + authority through its JWKS; callers use `HttpAuthority` instead of + `EmbeddedAuthority`. Nothing else changes. + +3. **Auth gateway** — browser frontends fanning out to multiple backends + add the optional gateway (see [The Auth Gateway](auth-gateway.md)). + +## Calling another service + +The calling side uses the outbound token framework (see +[Outbound Integrations](outbound-integrations.md)). For internal calls, +`RasInternalTokenSource` requests tokens from the authority — it holds no +keys and never mints locally: + +```rust,ignore +let source = Arc::new(RasInternalTokenSource::new( + Arc::new(EmbeddedAuthority::new(issuer.clone())), // or HttpAuthority + ServiceIdentityProof { service_id: "billing".into(), proof: secret_proof }, +)); +let manager = Arc::new(TokenManager::builder() + .register( + IntegrationConfig::new("invoice-service", ["invoice:read"], [invoice_url])? + .with_allowed_audiences(["invoice-service"]), + source, + )? + .build()); + +// In a handler: lease a token (cached, deduplicated) and call the +// generated client. +let lease = manager.get_token(TokenRequest { /* service subject, audience */ }).await?; +client.set_bearer_token(Some(lease.access_token.expose_secret())); +``` + +## Accepting RAS tokens + +The receiving side validates with `RasTokenAuthProvider`, a standard +`ras-auth-core` `AuthProvider`, so generated services enforce their +existing `WITH_PERMISSIONS` requirements unchanged: + +```rust,ignore +let provider = RasTokenAuthProvider::new(TokenValidator::new( + authority_jwks, + ValidationOptions::new(issuer_url, + AudiencePolicy::Exact("invoice-service".into()), + vec![TokenType::InternalService]), +)); +let app = InvoiceServiceBuilder::new(service).auth_provider(provider).build(); +``` + +A service that also sits behind the gateway composes two providers (one for +`ras_internal_access`, one for `ras_gateway_access`) — see +`examples/authorization-demo` for the complete wiring. + +## Relationship to existing identity crates + +`ras-identity-*` (identity providers, `SessionService`) continues to +authenticate humans and issue legacy HMAC sessions. The authorization +layer's web-session model (`RasClaims::web_session` with audience-grouped +permissions) is what the gateway consumes; migrating `SessionService` onto +`ras-authorization-token` signing is the planned follow-up and will be a +breaking change for session-token consumers. diff --git a/documentation/src/topology.md b/documentation/src/topology.md new file mode 100644 index 0000000..2cc45ef --- /dev/null +++ b/documentation/src/topology.md @@ -0,0 +1,90 @@ +# Topology + +Once several RAS services, an authority, and a gateway exist, hand-written +route/audience/grant configuration drifts: gateway routes outlive renamed +APIs, service grants reference deleted permissions, and a public gateway +can quietly expose a private service. The topology crates apply the RAS +philosophy one level up: declare the *logical* service graph in Rust, +validate it deterministically, and generate the artifacts everything else +consumes. + +## Declaring a topology + +```rust,ignore +ras_topology!({ + topology_name: InternalTools, + + services: [ + invoice: { + audience: "invoice-service", + manifest: invoice_api::generate_invoiceservice_permission_manifest, + exposure: private, + }, + billing: { + audience: "billing-service", + manifest: billing_api::generate_billingservice_permission_manifest, + exposure: private, + }, + ], + + gateways: [ + public_web: { + exposure: public, + routes: [ + "/api/invoice" => invoice { expose_private }, + "/api/billing" => billing { expose_private }, + ], + }, + ], + + calls: [ + billing -> invoice { + permissions: [invoice_api::invoiceservice_permissions::INVOICE_READ], + }, + ], +}); +``` + +This generates `internal_tools_topology() -> Result`. The topology crate sits *downstream* of the service API +crates, so the references are typed: a renamed manifest function or removed +permission constant fails the topology build immediately. + +## Validation levels + +- **Compile time** (the macro): duplicate service/gateway ids, routes and + call edges referencing undeclared services, and — via the typed paths — + existence of manifest functions and permission constants. +- **Build/test time** (`build()`): audience uniqueness, per-gateway route + conflicts (the same prefix on *different* gateways is fine), exposure + rules (a public gateway exposing a private service fails unless the route + is explicitly `expose_private`), and edge permissions checked against the + *target* service's manifest. Raw permission strings require the explicit + `custom_permissions` escape hatch. Run the generated function in a test + and the graph is checked on every CI build. +- **Startup time** (the consumers): deployment-provided upstream bindings + are validated when a gateway profile is loaded. + +## Generated artifacts + +All artifacts are byte-deterministic with stable content-derived ids, so +they diff cleanly in PRs and serve as audit input: + +- `topology.authz_policy_json()` — the allowed service-graph edges with + permission ceilings. Loads directly into the authority + (`issuer.load_policy(...)`), which then refuses to mint internal tokens + for undeclared edges or permissions beyond an edge's ceiling, *in + addition to* the grant checks. +- `topology.gateway_profile_toml("public_web")` — route → audience config + per gateway profile. Loads into + `GatewayConfig::from_profile_toml(...)` together with the deployment's + audience → upstream URL bindings. +- `topology.mermaid()` — a flowchart of gateways, services, routes, and + call edges for docs and reviews. + +## What topology does not own + +Deployment substrates: Kubernetes vs Compose vs systemd, ingress +controllers, DNS, meshes, certificates, and rollout lifecycle all stay +external. The topology dictates *auth* topology; the deployment binds +logical names to concrete upstreams when it loads the artifacts. diff --git a/examples/authorization-demo/Cargo.toml b/examples/authorization-demo/Cargo.toml new file mode 100644 index 0000000..8cf6afc --- /dev/null +++ b/examples/authorization-demo/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "authorization-demo" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +description = "End-to-end demo of the RAS authorization extension: embedded authority, internal service tokens, auth gateway, and topology artifacts" +license = "MIT OR Apache-2.0" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" +publish = false +readme = "README.md" + +[dependencies] +ras-auth-core = { path = "../../crates/core/ras-auth-core", version = "0.1.0" } +ras-authorization-core = { path = "../../crates/authorization/ras-authorization-core", version = "0.1.0" } +ras-authorization-gateway = { path = "../../crates/authorization/ras-authorization-gateway", version = "0.1.0" } +ras-authorization-token = { path = "../../crates/authorization/ras-authorization-token", version = "0.1.0" } +ras-integration-core = { path = "../../crates/integration/ras-integration-core", version = "0.1.0" } +ras-integration-ras = { path = "../../crates/integration/ras-integration-ras", version = "0.1.0" } +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" } +ras-rest-macro = { path = "../../crates/rest/ras-rest-macro", version = "0.2.1", default-features = false, features = ["permissions", "server", "client"] } +ras-topology-core = { path = "../../crates/topology/ras-topology-core", version = "0.1.0" } +ras-topology-macro = { path = "../../crates/topology/ras-topology-macro", version = "0.1.0" } +ras-transport-core = { path = "../../crates/core/ras-transport-core", version = "0.1.0", features = ["reqwest"] } + +async-trait = { workspace = true } +axum = { workspace = true } +axum-extra = { workspace = true } +chrono = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } + +[dev-dependencies] +ras-transport-core = { path = "../../crates/core/ras-transport-core", version = "0.1.0", features = ["axum-test"] } +axum-test = { workspace = true } +bytes = { workspace = true } +futures = { workspace = true } diff --git a/examples/authorization-demo/README.md b/examples/authorization-demo/README.md new file mode 100644 index 0000000..664f6a0 --- /dev/null +++ b/examples/authorization-demo/README.md @@ -0,0 +1,40 @@ +# authorization-demo + +End-to-end demo of the RAS authorization extension (#12–#15): two generated +RAS REST services, an embedded authority, the auth gateway, and a topology +declaration wiring them together. + +```text +browser ── ras_web_session ──> gateway ──/api/invoice──> invoice-service + │ + └──────/api/billing──> billing-service + │ RAS internal token + ▼ (embedded authority) + invoice-service +``` + +What it demonstrates: + +- `ras_topology!` declares the service graph; the generated policy artifact + constrains the authority's token issuance and the generated gateway + profile configures the gateway's routes (upstream bindings provided by the + deployment). +- The embedded authority registers services from the topology, imports their + generated permission manifests, and grants `billing -> invoice-service: + invoice:read`. +- Billing serves `/api/billing/summary` by acquiring a RAS internal token + through `TokenManager` + `RasInternalTokenSource` (embedded mode) and + calling the generated `InvoiceServiceClient` with it. +- The gateway validates web sessions locally and narrows them to + single-audience `ras_gateway_access` tokens; the invoice service accepts + both internal and gateway tokens through `RasTokenAuthProvider`s composed + with a small `MultiTokenAuthProvider`. +- Generated `WITH_PERMISSIONS` enforcement still applies per operation: a + read-only session passes `GET /invoices` and is rejected on + `POST /invoices`. + +Run the binary (`cargo run -p authorization-demo`) to serve the stack on +localhost with a printed demo session token, or see `tests/e2e.rs` for the +full in-process flow, including the fail-closed paths (missing audience +permissions, direct backend access with a session token, undeclared +topology edges). diff --git a/examples/authorization-demo/src/lib.rs b/examples/authorization-demo/src/lib.rs new file mode 100644 index 0000000..5f954df --- /dev/null +++ b/examples/authorization-demo/src/lib.rs @@ -0,0 +1,426 @@ +//! End-to-end demo of the RAS authorization extension. +//! +//! Two generated RAS REST services (invoice + billing), an embedded RAS +//! authority issuing internal service tokens, an auth gateway narrowing +//! browser web sessions to single-audience backend tokens, and a topology +//! declaration whose generated artifacts constrain both the authority and +//! the gateway. +//! +//! ```text +//! browser ── web session ──> gateway ──/api/invoice──> invoice-service +//! │ +//! └──/api/billing──> billing-service +//! │ internal token +//! ▼ (embedded authority) +//! invoice-service +//! ``` + +use std::sync::Arc; + +use ras_auth_core::{AuthError, AuthFuture, AuthProvider}; + +/// Invoice service: the downstream both the gateway and billing call. +pub mod invoice_api { + use ras_rest_macro::rest_service; + use schemars::JsonSchema; + use serde::{Deserialize, Serialize}; + + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] + pub struct Invoice { + pub id: String, + pub customer: String, + pub amount_cents: i64, + } + + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] + pub struct InvoicesResponse { + pub invoices: Vec, + } + + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] + pub struct CreateInvoiceRequest { + pub customer: String, + pub amount_cents: i64, + } + + rest_service!({ + service_name: InvoiceService, + base_path: "/api/invoice", + openapi: false, + endpoints: [ + GET WITH_PERMISSIONS(["invoice:read"]) invoices() -> InvoicesResponse, + POST WITH_PERMISSIONS(["invoice:write"]) invoices(CreateInvoiceRequest) -> Invoice, + ] + }); +} + +/// Billing service: calls the invoice service with RAS internal tokens. +pub mod billing_api { + use ras_rest_macro::rest_service; + use schemars::JsonSchema; + use serde::{Deserialize, Serialize}; + + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] + pub struct BillingSummary { + pub invoice_count: usize, + pub total_cents: i64, + } + + rest_service!({ + service_name: BillingService, + base_path: "/api/billing", + openapi: false, + endpoints: [ + GET WITH_PERMISSIONS(["billing:read"]) summary() -> BillingSummary, + ] + }); +} + +// The logical topology: services, the public gateway, and the one allowed +// service-to-service edge. The generated `authorization_demo_topology()` +// validates the graph and emits the policy/profile artifacts that the +// authority and gateway load below. +ras_topology_macro::ras_topology!({ + topology_name: AuthorizationDemo, + + services: [ + invoice: { + audience: "invoice-service", + manifest: crate::invoice_api::generate_invoiceservice_permission_manifest, + exposure: private, + }, + billing: { + audience: "billing-service", + manifest: crate::billing_api::generate_billingservice_permission_manifest, + exposure: private, + }, + ], + + gateways: [ + public_web: { + exposure: public, + routes: [ + "/api/invoice" => invoice { expose_private }, + "/api/billing" => billing { expose_private }, + ], + }, + ], + + calls: [ + billing -> invoice { + permissions: [ + crate::invoice_api::invoiceservice_permissions::INVOICE_READ, + ], + }, + ], +}); + +/// Accepts a token if any inner provider does (e.g. RAS internal tokens +/// *or* gateway-derived tokens). Providers are tried in order; the last +/// error wins when all fail. +pub struct MultiTokenAuthProvider { + providers: Vec>, +} + +impl MultiTokenAuthProvider { + pub fn new(providers: Vec>) -> Self { + Self { providers } + } +} + +impl AuthProvider for MultiTokenAuthProvider { + fn authenticate(&self, token: String) -> AuthFuture<'_> { + Box::pin(async move { + let mut last_error = AuthError::InvalidToken; + for provider in &self.providers { + match provider.authenticate(token.clone()).await { + Ok(user) => return Ok(user), + Err(err) => last_error = err, + } + } + Err(last_error) + }) + } +} + +/// Demo wiring shared by the binary and the integration tests. +pub mod demo { + use std::sync::Arc; + + use ras_authorization_core::{ + AudiencePermission, InMemoryAuditSink, InMemoryAuthorizationStore, Principal, + RasTokenAuthProvider, ServiceGraphPolicy, ServiceIdentityProof, ServiceRegistration, + StaticSecretVerifier, TokenIssuer, + }; + use ras_authorization_gateway::backend_validation_options; + use ras_authorization_token::{ + AudiencePolicy, JwkSet, SigningKey, TokenType, TokenValidator, ValidationOptions, + }; + use ras_integration_core::{IntegrationConfig, TokenManager, TokenRequest, TokenSubject}; + use ras_integration_ras::{EmbeddedAuthority, RasInternalTokenSource}; + use ras_rest_core::{RestError, RestResponse, RestResult}; + use ras_transport_core::HttpTransport; + use tokio::sync::Mutex; + + use crate::billing_api::{BillingServiceBuilder, BillingServiceTrait, BillingSummary}; + use crate::invoice_api::{ + CreateInvoiceRequest, Invoice, InvoiceServiceBuilder, InvoiceServiceClient, + InvoiceServiceTrait, InvoicesResponse, + }; + use crate::{MultiTokenAuthProvider, authorization_demo_topology}; + + pub const AUTHORITY_ISSUER: &str = "https://auth.internal"; + pub const GATEWAY_ISSUER: &str = "https://gateway.internal"; + pub const BILLING_SECRET: &str = "billing-service-demo-secret-32-bytes!!"; + + /// The embedded authority: registry, grants, issuer — constrained by + /// the topology's generated policy artifact. + pub struct Authority { + pub issuer: Arc, + pub store: Arc, + pub verifier: Arc, + pub audit: Arc, + } + + /// Build the authority from the topology: register every declared + /// service, import its manifest, grant the declared edge, and load the + /// generated policy. + pub async fn build_authority() -> Authority { + let topology = authorization_demo_topology().expect("topology must validate"); + + let store = Arc::new(InMemoryAuthorizationStore::new()); + let verifier = Arc::new(StaticSecretVerifier::new()); + let audit = Arc::new(InMemoryAuditSink::new()); + + for service in topology.services() { + store + .register_service(ServiceRegistration { + service_id: service.id.clone(), + display_name: service.id.clone(), + audience: service.audience.clone(), + enabled: true, + }) + .await + .expect("service registration"); + store + .import_manifest(&service.audience, &service.manifest) + .await + .expect("manifest import"); + } + verifier + .register("billing", BILLING_SECRET.as_bytes()) + .await + .expect("verifier registration"); + + // Grants mirror the declared topology edge. + store + .grant( + Principal::Service { + service_id: "billing".to_string(), + }, + AudiencePermission::new("invoice-service", "invoice:read"), + ) + .await + .expect("grant"); + + let issuer = Arc::new( + TokenIssuer::builder( + AUTHORITY_ISSUER, + SigningKey::generate_es256("authority-1"), + store.clone(), + verifier.clone(), + ) + .audit(audit.clone()) + .build(), + ); + + // The generated policy artifact constrains issuance to declared + // edges. + let policy: ServiceGraphPolicy = + serde_json::from_str(&topology.authz_policy_json().expect("policy artifact")) + .expect("policy artifact loads"); + issuer.load_policy(policy).await; + + Authority { + issuer, + store, + verifier, + audit, + } + } + + /// In-memory invoice service implementation. + pub struct InvoiceServiceImpl { + invoices: Mutex>, + } + + impl Default for InvoiceServiceImpl { + fn default() -> Self { + Self { + invoices: Mutex::new(vec![ + Invoice { + id: "inv-1".to_string(), + customer: "acme".to_string(), + amount_cents: 12_50, + }, + Invoice { + id: "inv-2".to_string(), + customer: "globex".to_string(), + amount_cents: 99_00, + }, + ]), + } + } + } + + #[async_trait::async_trait] + impl InvoiceServiceTrait for InvoiceServiceImpl { + async fn get_invoices( + &self, + _user: &ras_auth_core::AuthenticatedUser, + ) -> RestResult { + Ok(RestResponse::ok(InvoicesResponse { + invoices: self.invoices.lock().await.clone(), + })) + } + + async fn post_invoices( + &self, + _user: &ras_auth_core::AuthenticatedUser, + request: CreateInvoiceRequest, + ) -> RestResult { + let mut invoices = self.invoices.lock().await; + let invoice = Invoice { + id: format!("inv-{}", invoices.len() + 1), + customer: request.customer, + amount_cents: request.amount_cents, + }; + invoices.push(invoice.clone()); + Ok(RestResponse::ok(invoice)) + } + } + + /// Build the invoice router. It accepts RAS internal tokens (from + /// services like billing) and gateway-derived tokens (from browser + /// traffic) — both single-audience, both enforced by the generated + /// permission requirements. + pub fn build_invoice_router(authority_jwks: JwkSet, gateway_jwks: JwkSet) -> axum::Router { + let internal = RasTokenAuthProvider::new(TokenValidator::new( + authority_jwks, + ValidationOptions::new( + AUTHORITY_ISSUER, + AudiencePolicy::Exact("invoice-service".to_string()), + vec![TokenType::InternalService], + ), + )); + let from_gateway = RasTokenAuthProvider::new(TokenValidator::new( + gateway_jwks, + backend_validation_options(GATEWAY_ISSUER, "invoice-service"), + )); + InvoiceServiceBuilder::new(InvoiceServiceImpl::default()) + .auth_provider(MultiTokenAuthProvider::new(vec![ + Box::new(internal), + Box::new(from_gateway), + ])) + .build() + } + + /// Billing service implementation: serves `/summary` by calling the + /// invoice service with a RAS-issued internal token. + pub struct BillingServiceImpl { + token_manager: Arc, + invoice_client: InvoiceServiceClient, + } + + impl BillingServiceImpl { + pub fn new(token_manager: Arc, invoice_client: InvoiceServiceClient) -> Self { + Self { + token_manager, + invoice_client, + } + } + } + + #[async_trait::async_trait] + impl BillingServiceTrait for BillingServiceImpl { + async fn get_summary( + &self, + _user: &ras_auth_core::AuthenticatedUser, + ) -> RestResult { + // Acquire (or reuse from cache) an internal token for the + // invoice-service audience, then call the generated client. + let lease = self + .token_manager + .get_token(TokenRequest { + integration_id: "invoice-service".to_string(), + subject: TokenSubject::Service, + scopes: vec!["invoice:read".to_string()], + audience: Some("invoice-service".to_string()), + force_refresh: false, + }) + .await + .map_err(|err| { + RestError::internal_server_error(format!("token acquisition: {err}")) + })?; + + let mut client = self.invoice_client.clone(); + client.set_bearer_token(Some(lease.access_token.expose_secret())); + let invoices = client + .get_invoices() + .await + .map_err(|err| RestError::internal_server_error(format!("invoice call: {err}")))?; + + Ok(RestResponse::ok(BillingSummary { + invoice_count: invoices.invoices.len(), + total_cents: invoices + .invoices + .iter() + .map(|invoice| invoice.amount_cents) + .sum(), + })) + } + } + + /// Build the billing router: accepts gateway-derived tokens for its own + /// audience, and acquires internal tokens via the embedded authority to + /// call the invoice service. + pub fn build_billing_router( + authority: &Authority, + gateway_jwks: JwkSet, + invoice_base_url: &str, + invoice_transport: Arc, + ) -> axum::Router { + let source = Arc::new(RasInternalTokenSource::new( + Arc::new(EmbeddedAuthority::new(authority.issuer.clone())), + ServiceIdentityProof { + service_id: "billing".to_string(), + proof: serde_json::json!({ "client_secret": BILLING_SECRET }), + }, + )); + let token_manager = Arc::new( + TokenManager::builder() + .register( + IntegrationConfig::new("invoice-service", ["invoice:read"], [invoice_base_url]) + .expect("integration config") + .with_allowed_audiences(["invoice-service"]), + source, + ) + .expect("integration registration") + .build(), + ); + let invoice_client = InvoiceServiceClient::builder(invoice_base_url) + .build_with_transport(invoice_transport) + .expect("invoice client"); + + let from_gateway = RasTokenAuthProvider::new(TokenValidator::new( + gateway_jwks, + backend_validation_options(GATEWAY_ISSUER, "billing-service"), + )); + BillingServiceBuilder::new(BillingServiceImpl::new(token_manager, invoice_client)) + .auth_provider(from_gateway) + .build() + } +} + +pub use demo::{AUTHORITY_ISSUER, BILLING_SECRET, GATEWAY_ISSUER}; +pub type SharedAuthProvider = Arc; diff --git a/examples/authorization-demo/src/main.rs b/examples/authorization-demo/src/main.rs new file mode 100644 index 0000000..519e1a6 --- /dev/null +++ b/examples/authorization-demo/src/main.rs @@ -0,0 +1,115 @@ +//! Run the full demo stack on localhost: embedded authority, invoice and +//! billing services, and the auth gateway in front. +//! +//! ```text +//! cargo run -p authorization-demo +//! curl -H "Authorization: Bearer " \ +//! http://127.0.0.1:8080/api/invoice/invoices +//! curl -H "Authorization: Bearer " \ +//! http://127.0.0.1:8080/api/billing/summary +//! ``` + +use std::collections::BTreeMap; +use std::sync::Arc; + +use authorization_demo::authorization_demo_topology; +use authorization_demo::demo::{ + AUTHORITY_ISSUER, GATEWAY_ISSUER, build_authority, build_billing_router, build_invoice_router, +}; +use ras_authorization_gateway::{AuthGateway, GatewayConfig, gateway_router}; +use ras_authorization_token::{KeyResolver, KeyRing, RasClaims, SigningKey}; +use ras_transport_core::ReqwestTransport; + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt::init(); + + let topology = authorization_demo_topology().expect("topology validates"); + println!("--- topology diagram (mermaid) ---\n{}", topology.mermaid()); + + let authority = build_authority().await; + let authority_jwks = authority.issuer.jwks().await; + + // Web sessions: in this demo the session authority is a local keyring. + let session_keys = KeyRing::new(SigningKey::generate_es256("session-1")); + let session = RasClaims::web_session( + AUTHORITY_ISSUER, + "alice", + BTreeMap::from([ + ( + "invoice-service".to_string(), + vec!["invoice:read".to_string(), "invoice:write".to_string()], + ), + ( + "billing-service".to_string(), + vec!["billing:read".to_string()], + ), + ]), + chrono::Duration::hours(1), + ); + let session_token = session_keys.sign(&session).expect("session signs"); + + // Gateway config from the generated topology profile + deployment + // upstream bindings. + let profile = topology + .gateway_profile_toml("public_web") + .expect("gateway profile"); + let upstreams = BTreeMap::from([ + ( + "invoice-service".to_string(), + "http://127.0.0.1:8081".to_string(), + ), + ( + "billing-service".to_string(), + "http://127.0.0.1:8082".to_string(), + ), + ]); + let gateway_config = + GatewayConfig::from_profile_toml(AUTHORITY_ISSUER, GATEWAY_ISSUER, &profile, &upstreams) + .expect("gateway config"); + let gateway = Arc::new( + AuthGateway::new( + gateway_config, + Arc::new(session_keys.jwks()) as Arc, + SigningKey::generate_es256("gateway-1"), + ) + .expect("gateway"), + ); + let gateway_jwks = gateway.jwks(); + + let invoice_router = build_invoice_router(authority_jwks, gateway_jwks.clone()); + let billing_router = build_billing_router( + &authority, + gateway_jwks, + "http://127.0.0.1:8081", + Arc::new(ReqwestTransport::new()), + ); + let gateway_app = gateway_router(gateway, Arc::new(ReqwestTransport::new())); + + let invoice_listener = tokio::net::TcpListener::bind("127.0.0.1:8081") + .await + .expect("bind invoice"); + let billing_listener = tokio::net::TcpListener::bind("127.0.0.1:8082") + .await + .expect("bind billing"); + let gateway_listener = tokio::net::TcpListener::bind("127.0.0.1:8080") + .await + .expect("bind gateway"); + + println!("--- demo session token (alice) ---\n{session_token}\n"); + println!("gateway: http://127.0.0.1:8080 (routes /api/invoice, /api/billing)"); + println!("invoice: http://127.0.0.1:8081 (direct access requires RAS tokens)"); + println!("billing: http://127.0.0.1:8082"); + + let invoice = tokio::spawn(async move { + axum::serve(invoice_listener, invoice_router).await.unwrap(); + }); + let billing = tokio::spawn(async move { + axum::serve(billing_listener, billing_router).await.unwrap(); + }); + let gateway = tokio::spawn(async move { + axum::serve(gateway_listener, gateway_app).await.unwrap(); + }); + + let _ = tokio::try_join!(invoice, billing, gateway); +} diff --git a/examples/authorization-demo/tests/e2e.rs b/examples/authorization-demo/tests/e2e.rs new file mode 100644 index 0000000..cbd5c89 --- /dev/null +++ b/examples/authorization-demo/tests/e2e.rs @@ -0,0 +1,283 @@ +//! Full-stack integration test: browser session → gateway → generated RAS +//! services, with billing calling invoice through the embedded authority — +//! all in-process, no sockets. + +use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; + +use async_trait::async_trait; +use authorization_demo::authorization_demo_topology; +use authorization_demo::demo::{ + AUTHORITY_ISSUER, Authority, GATEWAY_ISSUER, build_authority, build_billing_router, + build_invoice_router, +}; +use ras_authorization_core::AuditEventKind; +use ras_authorization_gateway::{AuthGateway, GatewayConfig, gateway_router}; +use ras_authorization_token::{KeyResolver, KeyRing, RasClaims, SigningKey}; +use ras_integration_core::IntegrationError; +use ras_integration_ras::{EmbeddedAuthority, RasInternalTokenSource}; +use ras_transport_core::{ + AxumTestTransport, HttpTransport, TransportError, TransportRequest, TransportResponse, +}; + +const INVOICE_UPSTREAM: &str = "http://invoice-service:3000"; +const BILLING_UPSTREAM: &str = "http://billing-service:3000"; + +/// Routes proxied requests to in-process services by URL authority. +struct HostRoutingTransport { + routes: HashMap, +} + +#[async_trait] +impl HttpTransport for HostRoutingTransport { + async fn execute( + &self, + request: TransportRequest, + ) -> Result { + let authority = request + .url + .split("://") + .nth(1) + .and_then(|rest| rest.split('/').next()) + .unwrap_or_default() + .to_string(); + let transport = self + .routes + .get(&authority) + .ok_or_else(|| TransportError::Connection(format!("no upstream for {authority}")))?; + transport.execute(request).await + } +} + +struct Stack { + authority: Authority, + session_keys: KeyRing, + gateway: axum_test::TestServer, + invoice_direct: Arc, +} + +async fn build_stack() -> Stack { + let topology = authorization_demo_topology().expect("topology validates"); + let authority = build_authority().await; + let authority_jwks = authority.issuer.jwks().await; + + let session_keys = KeyRing::new(SigningKey::generate_es256("session-1")); + + // Gateway from the generated profile + in-process upstream bindings. + let profile = topology.gateway_profile_toml("public_web").unwrap(); + let upstreams = BTreeMap::from([ + ("invoice-service".to_string(), INVOICE_UPSTREAM.to_string()), + ("billing-service".to_string(), BILLING_UPSTREAM.to_string()), + ]); + let gateway = Arc::new( + AuthGateway::new( + GatewayConfig::from_profile_toml( + AUTHORITY_ISSUER, + GATEWAY_ISSUER, + &profile, + &upstreams, + ) + .unwrap(), + Arc::new(session_keys.jwks()) as Arc, + SigningKey::generate_es256("gateway-1"), + ) + .unwrap(), + ); + let gateway_jwks = gateway.jwks(); + + // Invoice service (accepts internal + gateway tokens). + let invoice_server = Arc::new( + axum_test::TestServer::new(build_invoice_router(authority_jwks, gateway_jwks.clone())) + .unwrap(), + ); + let invoice_transport = AxumTestTransport::from_arc(invoice_server.clone()); + + // Billing service (accepts gateway tokens; calls invoice internally). + let billing_router = build_billing_router( + &authority, + gateway_jwks, + INVOICE_UPSTREAM, + Arc::new(invoice_transport.clone()), + ); + let billing_server = axum_test::TestServer::new(billing_router).unwrap(); + + // Gateway proxying to both in-process services. + let upstream = Arc::new(HostRoutingTransport { + routes: HashMap::from([ + ("invoice-service:3000".to_string(), invoice_transport), + ( + "billing-service:3000".to_string(), + AxumTestTransport::new(billing_server), + ), + ]), + }); + let gateway_server = axum_test::TestServer::new(gateway_router(gateway, upstream)).unwrap(); + + Stack { + authority, + session_keys, + gateway: gateway_server, + invoice_direct: invoice_server, + } +} + +fn session(stack: &Stack, permissions: &[(&str, &[&str])]) -> String { + let audience_permissions = permissions + .iter() + .map(|(audience, permissions)| { + ( + audience.to_string(), + permissions.iter().map(|p| p.to_string()).collect(), + ) + }) + .collect(); + let claims = RasClaims::web_session( + AUTHORITY_ISSUER, + "alice", + audience_permissions, + chrono::Duration::minutes(30), + ); + stack.session_keys.sign(&claims).unwrap() +} + +#[tokio::test] +async fn browser_reads_invoices_through_the_gateway() { + let stack = build_stack().await; + let token = session(&stack, &[("invoice-service", &["invoice:read"])]); + + let response = stack + .gateway + .get("/api/invoice/invoices") + .authorization_bearer(&token) + .await; + response.assert_status_ok(); + let body: serde_json::Value = response.json(); + assert_eq!(body["invoices"].as_array().unwrap().len(), 2); +} + +#[tokio::test] +async fn billing_summary_calls_invoice_with_an_internal_token() { + let stack = build_stack().await; + let token = session(&stack, &[("billing-service", &["billing:read"])]); + + let response = stack + .gateway + .get("/api/billing/summary") + .authorization_bearer(&token) + .await; + response.assert_status_ok(); + let body: serde_json::Value = response.json(); + assert_eq!(body["invoice_count"], 2); + assert_eq!(body["total_cents"], 11150); + + // The internal billing -> invoice call went through the authority. + let events = stack.authority.audit.events().await; + assert!( + events + .iter() + .any(|event| event.kind == AuditEventKind::TokenIssued + && event.actor.as_deref() == Some("billing") + && event.target.as_deref() == Some("invoice-service")) + ); +} + +#[tokio::test] +async fn sessions_without_audience_permissions_fail_closed_at_the_gateway() { + let stack = build_stack().await; + // Session with only billing permissions cannot reach invoice routes. + let token = session(&stack, &[("billing-service", &["billing:read"])]); + stack + .gateway + .get("/api/invoice/invoices") + .authorization_bearer(&token) + .await + .assert_status(axum_test::http::StatusCode::FORBIDDEN); + + // No session at all: 401. Unknown route: 404. + stack + .gateway + .get("/api/invoice/invoices") + .await + .assert_status(axum_test::http::StatusCode::UNAUTHORIZED); + stack + .gateway + .get("/api/unknown") + .authorization_bearer(&token) + .await + .assert_status(axum_test::http::StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn narrowed_tokens_enforce_per_operation_permissions_downstream() { + let stack = build_stack().await; + // The session can read invoices but not write them. The gateway + // forwards (the audience has permissions), and the generated service + // enforces the per-operation requirement. + let token = session(&stack, &[("invoice-service", &["invoice:read"])]); + + stack + .gateway + .get("/api/invoice/invoices") + .authorization_bearer(&token) + .await + .assert_status_ok(); + + stack + .gateway + .post("/api/invoice/invoices") + .authorization_bearer(&token) + .json(&serde_json::json!({"customer": "initech", "amount_cents": 100})) + .await + .assert_status(axum_test::http::StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn web_session_tokens_are_rejected_by_backends_directly() { + let stack = build_stack().await; + let token = session( + &stack, + &[("invoice-service", &["invoice:read", "invoice:write"])], + ); + + // Bypassing the gateway with the multi-audience session token fails: + // backends only accept single-audience RAS tokens. + stack + .invoice_direct + .get("/api/invoice/invoices") + .authorization_bearer(&token) + .await + .assert_status(axum_test::http::StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn undeclared_topology_edges_are_denied_by_the_authority() { + let stack = build_stack().await; + // invoice -> billing is not a declared edge (and has no grant): the + // authority refuses even though the service identity is valid. + stack + .authority + .verifier + .register("invoice", "invoice-demo-secret-32-bytes-long!!!".as_bytes()) + .await + .unwrap(); + let source = RasInternalTokenSource::new( + Arc::new(EmbeddedAuthority::new(stack.authority.issuer.clone())), + ras_authorization_core::ServiceIdentityProof { + service_id: "invoice".to_string(), + proof: serde_json::json!({ "client_secret": "invoice-demo-secret-32-bytes-long!!!" }), + }, + ); + let err = ras_integration_core::TokenSource::issue_token( + &source, + &ras_integration_core::TokenRequest { + integration_id: "billing-service".to_string(), + subject: ras_integration_core::TokenSubject::Service, + scopes: vec![], + audience: Some("billing-service".to_string()), + force_refresh: false, + }, + ) + .await + .unwrap_err(); + assert!(matches!(err, IntegrationError::Denied { .. })); +}