Skip to content

Commit bf37b29

Browse files
authored
Add bindings for csharp modules to use JWT claims (#3414)
# Description of Changes This exposes JWT claims for csharp modules, similar to how they are exposed to rust modules in #3288. This adds the new types `AuthCtx` and `JwtClaims`, and adds an `AuthCtx` to the `ReducerContext`. `AuthCtx` represents the credentials associated with the request, and `JwtClaims` represents a jwt token. One difference from the rust version is that I didn't create helpers to build an `AuthCtx` from a jwt payload. The reason is that we would need to be able to compute the identity from the payload claims, which requires a blake3 hash implementation. The first two c# libraries I found had issues at runtime ([Blake3](https://www.nuget.org/packages/Blake3) is wrapping a rust implementation, and [HashifyNet](https://github.com/Deskasoft/HashifyNET/tree/main/HashifyNet/Algorithms/Blake3) seems to be broken by our trimming because it uses reflection heavily). I can look into taking the implementation from `HashifyNet`, since it is MIT licensed, but I don't think we need to block merging on that. # API and ABI breaking changes This adds the new types `AuthCtx` and `JwtClaims`, and adds an `AuthCtx` to the `ReducerContext`. This also adds a csharp wrapper for the get_jwt ABI function added in #3288. # Expected complexity level and risk 2. # Testing This has a very minimal unit test of JwtClaims. I manually tested using this locally with the csharp quickstart, and I was able to print jwt tokens inside the module.
1 parent dac57e4 commit bf37b29

13 files changed

Lines changed: 246 additions & 3 deletions

File tree

crates/bindings-csharp/Codegen.Tests/fixtures/diag/snapshots/Module#FFI.verified.cs

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/bindings-csharp/Codegen.Tests/fixtures/server/snapshots/Module#FFI.verified.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ public sealed record ReducerContext : DbContext<Local>, Internal.IReducerContext
1414
public readonly ConnectionId? ConnectionId;
1515
public readonly Random Rng;
1616
public readonly Timestamp Timestamp;
17+
public readonly AuthCtx AuthCtx;
1718

1819
// We need this property to be non-static for parity with client SDK.
1920
public Identity Identity => Internal.IReducerContext.GetIdentity();
@@ -29,6 +30,7 @@ Timestamp time
2930
ConnectionId = connectionId;
3031
Rng = random;
3132
Timestamp = time;
33+
AuthCtx = AuthCtx.BuildFromSystemTables(connectionId, identity);
3234
}
3335
}
3436

crates/bindings-csharp/Codegen/Module.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1111,6 +1111,7 @@ public sealed record ReducerContext : DbContext<Local>, Internal.IReducerContext
11111111
public readonly ConnectionId? ConnectionId;
11121112
public readonly Random Rng;
11131113
public readonly Timestamp Timestamp;
1114+
public readonly AuthCtx AuthCtx;
11141115
11151116
// We need this property to be non-static for parity with client SDK.
11161117
public Identity Identity => Internal.IReducerContext.GetIdentity();
@@ -1120,6 +1121,7 @@ internal ReducerContext(Identity identity, ConnectionId? connectionId, Random ra
11201121
ConnectionId = connectionId;
11211122
Rng = random;
11221123
Timestamp = time;
1124+
AuthCtx = AuthCtx.BuildFromSystemTables(connectionId, identity);
11231125
}
11241126
}
11251127
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
namespace Runtime.Tests;
2+
3+
using SpacetimeDB;
4+
5+
public class JwtClaimsTest
6+
{
7+
[Fact]
8+
public void TestSubject()
9+
{
10+
var jwt = new JwtClaims(
11+
"{\"sub\":\"123\",\"name\":\"John Doe\",\"iss\":\"example.com\"}",
12+
Identity.FromHexString(
13+
"c200ef884364c1a99be0298dc68f2004e6b97c09d1b1658b7db22f51fb662059"
14+
)
15+
);
16+
Assert.Equal("123", jwt.Subject);
17+
}
18+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net8.0</TargetFramework>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<Nullable>enable</Nullable>
7+
8+
<IsPackable>false</IsPackable>
9+
<IsTestProject>true</IsTestProject>
10+
</PropertyGroup>
11+
12+
<ItemGroup>
13+
<PackageReference Include="coverlet.collector" Version="6.0.0" />
14+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
15+
<PackageReference Include="xunit" Version="2.5.3" />
16+
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
17+
</ItemGroup>
18+
19+
<ItemGroup>
20+
<Using Include="Xunit" />
21+
</ItemGroup>
22+
23+
<ItemGroup>
24+
<ProjectReference Include="..\Runtime\Runtime.csproj" />
25+
</ItemGroup>
26+
27+
</Project>
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
namespace SpacetimeDB;
2+
3+
using System;
4+
5+
public sealed class AuthCtx
6+
{
7+
private readonly bool _isInternal;
8+
private readonly Lazy<JwtClaims?> _jwtLazy;
9+
10+
private AuthCtx(bool isInternal, Func<JwtClaims?> jwtFactory)
11+
{
12+
_isInternal = isInternal;
13+
_jwtLazy = new Lazy<JwtClaims?>(() => jwtFactory?.Invoke());
14+
}
15+
16+
/// <summary>
17+
/// Create an AuthCtx for an internal call, with no JWT.
18+
/// </summary>
19+
private static AuthCtx Internal()
20+
{
21+
return new AuthCtx(isInternal: true, jwtFactory: () => null);
22+
}
23+
24+
/// <summary>
25+
/// Create an AuthCtx by looking up the credentials for a connection id in system tables.
26+
///
27+
/// Ideally this would not be part of the public API.
28+
/// This should only be called inside of a reducer.
29+
/// </summary>
30+
public static AuthCtx BuildFromSystemTables(ConnectionId? connectionId, Identity identity)
31+
{
32+
if (connectionId == null)
33+
{
34+
return Internal();
35+
}
36+
return FromConnectionId(connectionId.Value, identity);
37+
}
38+
39+
/// <summary>
40+
/// Create an AuthCtx that reads JWT for a given connection ID.
41+
/// </summary>
42+
private static AuthCtx FromConnectionId(ConnectionId connectionId, Identity identity)
43+
{
44+
return new AuthCtx(
45+
isInternal: false,
46+
jwtFactory: () =>
47+
{
48+
var result = SpacetimeDB.Internal.FFI.get_jwt(ref connectionId, out var source);
49+
SpacetimeDB.Internal.FFI.CheckedStatus.Marshaller.ConvertToManaged(result);
50+
var bytes = SpacetimeDB.Internal.Module.Consume(source);
51+
if (bytes == null || bytes.Length == 0)
52+
{
53+
return null;
54+
}
55+
var jwt = System.Text.Encoding.UTF8.GetString(bytes);
56+
return jwt != null ? new JwtClaims(jwt, identity) : null;
57+
}
58+
);
59+
}
60+
61+
/// <summary>
62+
/// True if this reducer was spawned from inside the database.
63+
/// </summary>
64+
public bool IsInternal => _isInternal;
65+
66+
/// <summary>
67+
/// Check if there is a JWT present.
68+
/// If IsInternal is true, this will be false.
69+
/// </summary>
70+
public bool HasJwt
71+
{
72+
get
73+
{
74+
if (_isInternal)
75+
{
76+
return false;
77+
}
78+
79+
// At this point we do load the bytes.
80+
return _jwtLazy.Value != null;
81+
}
82+
}
83+
84+
/// <summary>
85+
/// Load and get the JwtClaims.
86+
/// </summary>
87+
public JwtClaims? Jwt => _jwtLazy.Value;
88+
}

crates/bindings-csharp/Runtime/Internal/FFI.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,14 @@ internal static partial class FFI
6161
#endif
6262
;
6363

64+
const string StdbNamespace10_2 =
65+
#if EXPERIMENTAL_WASM_AOT
66+
"spacetime_10.2"
67+
#else
68+
"bindings"
69+
#endif
70+
;
71+
6472
[NativeMarshalling(typeof(Marshaller))]
6573
public struct CheckedStatus
6674
{
@@ -307,4 +315,7 @@ uint args_len
307315

308316
[DllImport(StdbNamespace10_1)]
309317
public static extern Errno bytes_source_remaining_length(BytesSource source, ref uint len);
318+
319+
[DllImport(StdbNamespace10_2)]
320+
public static extern Errno get_jwt(ref ConnectionId connectionId, out BytesSource source);
310321
}

crates/bindings-csharp/Runtime/Internal/Module.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ public static void RegisterClientVisibilityFilter(Filter rlsFilter)
123123
public static void RegisterTableDefaultValue(string table, ushort colId, byte[] value) =>
124124
moduleDef.RegisterTableDefaultValue(table, colId, value);
125125

126-
private static byte[] Consume(this BytesSource source)
126+
public static byte[] Consume(this BytesSource source)
127127
{
128128
if (source == BytesSource.INVALID)
129129
{
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
namespace SpacetimeDB;
2+
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Linq;
6+
using System.Text.Json;
7+
8+
public sealed class JwtClaims
9+
{
10+
private readonly string _payload;
11+
private readonly Lazy<JsonDocument> _parsed;
12+
private readonly Lazy<List<string>> _audience;
13+
14+
public Identity Identity { get; }
15+
16+
/// <summary>
17+
/// Create a JwtClaims from a raw JWT payload (JSON claims) and its associated Identity.
18+
///
19+
/// This only takes an Identity because the Blake3 hash package on nuget wraps rust code.
20+
/// We should not expose this constructor publicly, but it is needed for AuthCtx.
21+
/// </summary>
22+
internal JwtClaims(string jwt, Identity identity)
23+
{
24+
_payload = jwt ?? throw new ArgumentNullException(nameof(jwt));
25+
_parsed = new Lazy<JsonDocument>(() => JsonDocument.Parse(_payload));
26+
_audience = new Lazy<List<string>>(ExtractAudience);
27+
Identity = identity;
28+
}
29+
30+
private JsonDocument Parsed => _parsed.Value;
31+
32+
private JsonElement RootElement => Parsed.RootElement;
33+
34+
public string Subject
35+
{
36+
get
37+
{
38+
if (
39+
RootElement.TryGetProperty("sub", out var sub)
40+
&& sub.ValueKind == JsonValueKind.String
41+
)
42+
{
43+
return sub.GetString()!;
44+
}
45+
46+
throw new InvalidOperationException("JWT missing or invalid 'sub' claim");
47+
}
48+
}
49+
50+
public string Issuer
51+
{
52+
get
53+
{
54+
if (
55+
RootElement.TryGetProperty("iss", out var iss)
56+
&& iss.ValueKind == JsonValueKind.String
57+
)
58+
{
59+
return iss.GetString()!;
60+
}
61+
62+
throw new InvalidOperationException("JWT missing or invalid 'iss' claim");
63+
}
64+
}
65+
66+
private List<string> ExtractAudience()
67+
{
68+
if (!RootElement.TryGetProperty("aud", out var aud))
69+
{
70+
throw new InvalidOperationException("JWT missing 'aud' claim");
71+
}
72+
73+
return aud.ValueKind switch
74+
{
75+
JsonValueKind.String => new List<string> { aud.GetString()! },
76+
JsonValueKind.Array => aud.EnumerateArray()
77+
.Where(e => e.ValueKind == JsonValueKind.String)
78+
.Select(e => e.GetString()!)
79+
.ToList(),
80+
_ => throw new InvalidOperationException("Unexpected type for 'aud' claim in JWT"),
81+
};
82+
}
83+
84+
public IReadOnlyList<string> Audience => _audience.Value;
85+
86+
// TODO: Should this be exposed as a JsonDocument, since that it in the stdlib?
87+
public string RawPayload => _payload;
88+
}

crates/bindings-csharp/Runtime/bindings.c

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@ IMPORT(void, identity, (void* id_ptr), (id_ptr));
104104
IMPORT(int16_t, bytes_source_remaining_length, (BytesSource source, uint32_t* out), (source, out));
105105
#undef SPACETIME_MODULE_VERSION
106106

107+
#define SPACETIME_MODULE_VERSION "spacetime_10.2"
108+
IMPORT(int16_t, get_jwt, (const uint8_t* connection_id_ptr, BytesSource* bytes_ptr), (connection_id_ptr, bytes_ptr));
109+
#undef SPACETIME_MODULE_VERSION
110+
107111
#ifndef EXPERIMENTAL_WASM_AOT
108112
static MonoClass* ffi_class;
109113

0 commit comments

Comments
 (0)