Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/bindings-cpp/include/spacetimedb/auth_ctx.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::optional<JwtClaims>> jwt_;
Expand Down
4 changes: 2 additions & 2 deletions crates/bindings-cpp/include/spacetimedb/handler_context.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
31 changes: 31 additions & 0 deletions crates/bindings-cpp/tests/unit/http_unit_tests.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#include "test_harness.h"

#include "spacetimedb/http_convert.h"
#include "spacetimedb/handler_context.h"

#include <string>
#include <utility>
Expand Down Expand Up @@ -54,3 +55,33 @@ TEST_CASE(response_into_wire_splits_metadata_and_body) {
ASSERT_EQ(std::vector<uint8_t>({'o','k'}), response_meta.headers.entries[1].value);
ASSERT_EQ(std::vector<uint8_t>({'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<uint16_t>(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);
}
}
36 changes: 36 additions & 0 deletions crates/bindings-csharp/Runtime.Tests/HandlerContextTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
namespace Runtime.Tests;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have this project Runtime.Tests but it's not used: it's not part of the solution and it's not invoked in CI. So we should move this out of here.
We should probably move it to be alongside the other C# tests inside of http_routes.rs.


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);
}
}
2 changes: 2 additions & 0 deletions crates/bindings-csharp/Runtime/AuthCtx.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ private AuthCtx(bool isInternal, Func<JwtClaims?> jwtFactory)
_jwtLazy = new Lazy<JwtClaims?>(() => jwtFactory?.Invoke());
}

internal static AuthCtx Anonymous() => new(isInternal: false, jwtFactory: () => null);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would make this a static readonly field just to avoid a few small unnecessary allocations:

internal static readonly AuthCtx Anonymous = new(isInternal: false, jwtFactory: static () => null);


/// <summary>
/// Create an AuthCtx for an internal call, with no JWT.
/// </summary>
Expand Down
2 changes: 1 addition & 1 deletion crates/bindings-csharp/Runtime/HandlerContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ protected HandlerContextBase(Random random, Timestamp time)
default,
null,
timestamp,
AuthCtx.BuildFromSystemTables(null, default),
AuthCtx.Anonymous(),
random
),
inner => CreateTxContext(inner)
Expand Down
1 change: 1 addition & 0 deletions crates/bindings-csharp/Runtime/Runtime.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
<ItemGroup>
<UpToDateCheckInput Include="bindings.c" />
<UpToDateCheckInput Include="driver.h" />
<InternalsVisibleTo Include="Runtime.Tests" />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should remove this because we should not be using Runtime.Tests.

</ItemGroup>

</Project>
74 changes: 72 additions & 2 deletions crates/bindings/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,12 +143,12 @@ impl HandlerContext {

/// Acquire a mutable transaction and execute `body` with read-write access.
pub fn with_tx<T>(&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<T, E>(&mut self, body: impl Fn(&TxContext) -> Result<T, E>) -> Result<T, E> {
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.
Expand Down Expand Up @@ -824,6 +824,76 @@ impl From<http::Error> 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<u16> = 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 {
Expand Down
20 changes: 15 additions & 5 deletions crates/bindings/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1272,6 +1272,7 @@ fn try_with_tx<T, E>(
body: impl Fn(&TxContext) -> Result<T, E>,
identity: Identity,
connection_id: Option<ConnectionId>,
is_http_handler: bool,
) -> Result<T, E> {
let abort = || {
crate::sys::procedure::procedure_abort_mut_tx()
Expand All @@ -1283,7 +1284,11 @@ fn try_with_tx<T, E>(
.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: Fn()>(F);
Expand Down Expand Up @@ -1316,9 +1321,14 @@ fn try_with_tx<T, E>(
res
}

fn with_tx<T>(body: impl Fn(&TxContext) -> T, identity: Identity, connection_id: Option<ConnectionId>) -> T {
fn with_tx<T>(
body: impl Fn(&TxContext) -> T,
identity: Identity,
connection_id: Option<ConnectionId>,
is_http_handler: bool,
) -> T {
use core::convert::Infallible;
match try_with_tx::<T, Infallible>(|tx| Ok(body(tx)), identity, connection_id) {
match try_with_tx::<T, Infallible>(|tx| Ok(body(tx)), identity, connection_id, is_http_handler) {
Ok(v) => v,
Err(e) => match e {},
}
Expand Down Expand Up @@ -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<T>(&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
Expand Down Expand Up @@ -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<T, E>(&mut self, body: impl Fn(&TxContext) -> Result<T, E>) -> Result<T, E> {
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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

<Tabs groupId="server-language" queryString>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading