diff --git a/crates/bindings-cpp/include/spacetimedb/auth_ctx.h b/crates/bindings-cpp/include/spacetimedb/auth_ctx.h index 00a4898e020..3bc62f21997 100644 --- a/crates/bindings-cpp/include/spacetimedb/auth_ctx.h +++ b/crates/bindings-cpp/include/spacetimedb/auth_ctx.h @@ -26,6 +26,8 @@ struct ConnectionId; * This class uses lazy loading - the JWT is only fetched and parsed when accessed. */ class AuthCtx { + friend struct HandlerContext; + private: bool is_internal_; mutable std::shared_ptr> jwt_; diff --git a/crates/bindings-cpp/include/spacetimedb/handler_context.h b/crates/bindings-cpp/include/spacetimedb/handler_context.h index 80f42d821c5..ec6fea1de47 100644 --- a/crates/bindings-cpp/include/spacetimedb/handler_context.h +++ b/crates/bindings-cpp/include/spacetimedb/handler_context.h @@ -68,7 +68,7 @@ struct HandlerContext { Identity{}, std::nullopt, tx_timestamp, - AuthCtx::internal() + AuthCtx(false, [] { return std::nullopt; }) ); }; return Internal::with_tx(make_reducer_ctx, body); @@ -81,7 +81,7 @@ struct HandlerContext { Identity{}, std::nullopt, tx_timestamp, - AuthCtx::internal() + AuthCtx(false, [] { return std::nullopt; }) ); }; return Internal::try_with_tx(make_reducer_ctx, body); diff --git a/crates/bindings-cpp/tests/unit/http_unit_tests.cpp b/crates/bindings-cpp/tests/unit/http_unit_tests.cpp index 16d98db8f09..043d1384e99 100644 --- a/crates/bindings-cpp/tests/unit/http_unit_tests.cpp +++ b/crates/bindings-cpp/tests/unit/http_unit_tests.cpp @@ -1,6 +1,7 @@ #include "test_harness.h" #include "spacetimedb/http_convert.h" +#include "spacetimedb/handler_context.h" #include #include @@ -54,3 +55,33 @@ TEST_CASE(response_into_wire_splits_metadata_and_body) { ASSERT_EQ(std::vector({'o','k'}), response_meta.headers.entries[1].value); ASSERT_EQ(std::vector({'c','r','e','a','t','e','d'}), response_body); } + +namespace { +size_t commit_attempts; +} + +extern "C" Status procedure_start_mut_tx(int64_t* out) { *out = 0; return Status{0}; } +extern "C" Status procedure_commit_mut_tx() { + return Status{static_cast(commit_attempts++ == 0 ? 1 : 0)}; +} +extern "C" Status procedure_abort_mut_tx() { return Status{0}; } + +TEST_CASE(handler_transactions_are_external_without_jwt_even_on_retry) { + HandlerContext handler; + for (bool fallible : {false, true}) { + commit_attempts = 0; + size_t calls = 0; + auto check = [&](TxContext& tx) { + ++calls; + ASSERT_TRUE(!tx.sender_auth().is_internal()); + ASSERT_TRUE(!tx.sender_auth().has_jwt()); + ASSERT_TRUE(!tx.sender_auth().get_jwt().has_value()); + }; + if (fallible) { + ASSERT_TRUE(handler.try_with_tx([&](TxContext& tx) { check(tx); return true; })); + } else { + handler.with_tx(check); + } + ASSERT_EQ(size_t{2}, calls); + } +} diff --git a/crates/bindings-csharp/Runtime.Tests/HandlerContextTests.cs b/crates/bindings-csharp/Runtime.Tests/HandlerContextTests.cs new file mode 100644 index 00000000000..57873571281 --- /dev/null +++ b/crates/bindings-csharp/Runtime.Tests/HandlerContextTests.cs @@ -0,0 +1,36 @@ +namespace Runtime.Tests; + +using SpacetimeDB; + +public class HandlerContextTests +{ + private sealed class TestLocal : LocalBase { } + + private sealed class TestTxContext(SpacetimeDB.Internal.TxContext inner) + : HandlerTxContextBase(inner) { } + + private sealed class TestHandlerContext() : HandlerContextBase(new Random(0), new Timestamp(0)) + { + protected override LocalBase CreateLocal() => new TestLocal(); + + protected override HandlerTxContextBase CreateTxContext(SpacetimeDB.Internal.TxContext inner) => + new TestTxContext(inner); + } + + [Fact] + public void HandlerTransactionsAreExternalWithoutJwt() + { + var handler = new TestHandlerContext(); + // EnterTxContext is the construction path used by WithTx and TryWithTx, + // including when a failed commit is retried with a new timestamp. + for (long timestamp = 0; timestamp < 3; timestamp++) + { + var auth = handler.EnterTxContext(timestamp).SenderAuth; + Assert.False(auth.IsInternal); + Assert.False(auth.HasJwt); + Assert.Null(auth.Jwt); + } + handler.ExitTxContext(); + Assert.False(handler.EnterTxContext(3).SenderAuth.IsInternal); + } +} diff --git a/crates/bindings-csharp/Runtime/AuthCtx.cs b/crates/bindings-csharp/Runtime/AuthCtx.cs index c7fcdea47e0..679dff924b5 100644 --- a/crates/bindings-csharp/Runtime/AuthCtx.cs +++ b/crates/bindings-csharp/Runtime/AuthCtx.cs @@ -15,6 +15,8 @@ private AuthCtx(bool isInternal, Func jwtFactory) _jwtLazy = new Lazy(() => jwtFactory?.Invoke()); } + internal static AuthCtx Anonymous() => new(isInternal: false, jwtFactory: () => null); + /// /// Create an AuthCtx for an internal call, with no JWT. /// diff --git a/crates/bindings-csharp/Runtime/HandlerContext.cs b/crates/bindings-csharp/Runtime/HandlerContext.cs index 9fc02d8b858..e465cee2d3b 100644 --- a/crates/bindings-csharp/Runtime/HandlerContext.cs +++ b/crates/bindings-csharp/Runtime/HandlerContext.cs @@ -28,7 +28,7 @@ protected HandlerContextBase(Random random, Timestamp time) default, null, timestamp, - AuthCtx.BuildFromSystemTables(null, default), + AuthCtx.Anonymous(), random ), inner => CreateTxContext(inner) diff --git a/crates/bindings-csharp/Runtime/Runtime.csproj b/crates/bindings-csharp/Runtime/Runtime.csproj index 295175495ca..609a1ec6c48 100644 --- a/crates/bindings-csharp/Runtime/Runtime.csproj +++ b/crates/bindings-csharp/Runtime/Runtime.csproj @@ -52,6 +52,7 @@ + diff --git a/crates/bindings/src/http.rs b/crates/bindings/src/http.rs index 3638d35f9e2..94fe822db20 100644 --- a/crates/bindings/src/http.rs +++ b/crates/bindings/src/http.rs @@ -143,12 +143,12 @@ impl HandlerContext { /// Acquire a mutable transaction and execute `body` with read-write access. pub fn with_tx(&mut self, body: impl Fn(&TxContext) -> T) -> T { - with_tx(body, Identity::ZERO, None) + with_tx(body, Identity::ZERO, None, true) } /// Acquire a mutable transaction and execute `body` with read-write access. pub fn try_with_tx(&mut self, body: impl Fn(&TxContext) -> Result) -> Result { - try_with_tx(body, Identity::ZERO, None) + try_with_tx(body, Identity::ZERO, None, true) } /// Create a new random [`Uuid`] `v4` using the built-in RNG. @@ -824,6 +824,76 @@ impl From for Error { mod tests { use super::*; + // Native host stubs exercise the real transaction path, including a commit retry. + std::thread_local! { + static COMMIT_ATTEMPTS: std::cell::Cell = const { std::cell::Cell::new(0) }; + } + + #[unsafe(no_mangle)] + unsafe extern "C" fn procedure_start_mut_tx(out: *mut i64) -> u16 { + unsafe { out.write(0) }; + 0 + } + + #[unsafe(no_mangle)] + extern "C" fn procedure_commit_mut_tx() -> u16 { + COMMIT_ATTEMPTS.with(|attempts| { + let previous = attempts.replace(attempts.get() + 1); + // Fail the first commit to force reconstruction of the transaction context. + if previous == 0 { + 1 + } else { + 0 + } + }) + } + + #[unsafe(no_mangle)] + extern "C" fn procedure_abort_mut_tx() -> u16 { + 0 + } + + #[unsafe(no_mangle)] + extern "C" fn get_jwt(_: *const u8, _: *mut crate::sys::raw::BytesSource) -> u16 { + panic!("handler authentication must not read a JWT") + } + + #[unsafe(no_mangle)] + extern "C" fn bytes_source_read(_: crate::sys::raw::BytesSource, _: *mut u8, _: *mut usize) -> i16 { + panic!("handler authentication must not read a JWT") + } + + #[unsafe(no_mangle)] + extern "C" fn bytes_source_remaining_length(_: crate::sys::raw::BytesSource, _: *mut u32) -> i16 { + panic!("handler authentication must not read a JWT") + } + + #[test] + fn handler_transactions_are_external_without_jwt_even_on_retry() { + let mut handler = HandlerContext::new(Timestamp::UNIX_EPOCH); + for fallible in [false, true] { + COMMIT_ATTEMPTS.with(|attempts| attempts.set(0)); + let calls = std::cell::Cell::new(0); + let check = |tx: &TxContext| { + calls.set(calls.get() + 1); + assert!(!tx.sender_auth().is_internal()); + assert!(!tx.sender_auth().has_jwt()); + assert!(tx.sender_auth().jwt().is_none()); + }; + if fallible { + handler + .try_with_tx(|tx| { + check(tx); + Ok::<_, ()>(()) + }) + .unwrap(); + } else { + handler.with_tx(check); + } + assert_eq!(calls.get(), 2); + } + } + #[test] fn request_from_wire_preserves_metadata_and_body() { let request = st_http::Request { diff --git a/crates/bindings/src/lib.rs b/crates/bindings/src/lib.rs index 6c897bc333d..47cdd9e07cf 100644 --- a/crates/bindings/src/lib.rs +++ b/crates/bindings/src/lib.rs @@ -1272,6 +1272,7 @@ fn try_with_tx( body: impl Fn(&TxContext) -> Result, identity: Identity, connection_id: Option, + is_http_handler: bool, ) -> Result { let abort = || { crate::sys::procedure::procedure_abort_mut_tx() @@ -1283,7 +1284,11 @@ fn try_with_tx( .expect("holding `&mut HandlerContext`, so should not be in a tx already; called manually elsewhere?"); let timestamp = Timestamp::from_micros_since_unix_epoch(timestamp); - let tx = ReducerContext::new(crate::Local {}, identity, connection_id, timestamp); + let mut tx = ReducerContext::new(crate::Local {}, identity, connection_id, timestamp); + if is_http_handler { + // HTTP requests have no connection ID, but are not host-originated calls. + tx.sender_auth = AuthCtx::new(false, || None); + } let tx = TxContext(tx); struct DoOnDrop(F); @@ -1316,9 +1321,14 @@ fn try_with_tx( res } -fn with_tx(body: impl Fn(&TxContext) -> T, identity: Identity, connection_id: Option) -> T { +fn with_tx( + body: impl Fn(&TxContext) -> T, + identity: Identity, + connection_id: Option, + is_http_handler: bool, +) -> T { use core::convert::Infallible; - match try_with_tx::(|tx| Ok(body(tx)), identity, connection_id) { + match try_with_tx::(|tx| Ok(body(tx)), identity, connection_id, is_http_handler) { Ok(v) => v, Err(e) => match e {}, } @@ -1459,7 +1469,7 @@ impl ProcedureContext { /// callers should avoid writing to any captured mutable state within `body`, /// This includes interior mutability through types like [`std::cell::Cell`]. pub fn with_tx(&mut self, body: impl Fn(&TxContext) -> T) -> T { - with_tx(body, self.sender(), self.connection_id()) + with_tx(body, self.sender(), self.connection_id(), false) } /// Acquire a mutable transaction @@ -1492,7 +1502,7 @@ impl ProcedureContext { /// callers should avoid writing to any captured mutable state within `body`, /// This includes interior mutability through types like [`std::cell::Cell`]. pub fn try_with_tx(&mut self, body: impl Fn(&TxContext) -> Result) -> Result { - try_with_tx(body, self.sender(), self.connection_id()) + try_with_tx(body, self.sender(), self.connection_id(), false) } /// Create a new random [`Uuid`] `v4` using the built-in RNG. diff --git a/docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md b/docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md index 66dbe4425c3..360b0f93241 100644 --- a/docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md +++ b/docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md @@ -13,6 +13,8 @@ External clients can make HTTP requests to routes nested under [`/v1/database/:n ***HTTP handlers are currently in beta, and their API may change in upcoming SpacetimeDB releases.*** ::: +Transaction contexts inside HTTP handlers are external: `is_internal()` is false and no JWT is available through the authentication context. SpacetimeDB does not authenticate the `Authorization` header on user-defined routes; handlers must validate any credentials they require. + ## Defining HTTP Handlers diff --git a/docs/docs/00200-core-concepts/00500-authentication/00500-usage.md b/docs/docs/00200-core-concepts/00500-authentication/00500-usage.md index a022cc1dc65..ea28b17a1e8 100644 --- a/docs/docs/00200-core-concepts/00500-authentication/00500-usage.md +++ b/docs/docs/00200-core-concepts/00500-authentication/00500-usage.md @@ -209,6 +209,8 @@ SPACETIMEDB_CLIENT_CONNECTED(restrict_auth_provider_connect, ReducerContext ctx) ## Accessing custom claims +The internal-call shortcut in the examples below applies to host-originated calls such as scheduled reducers, never to HTTP handler transactions. + If you want to access additional claims that aren't available via helper functions, you can parse the full JWT payload. This is useful for handling custom or application-specific claims. As an example, let's say that your tokens have a "roles" claim, which is a list of priviledges. If you want to make sure that only users with the `admin` role are able to call a certain reducer, you could do the following: