Skip to content

Commit d111139

Browse files
committed
First pass on documentation for auth claims
1 parent 833f750 commit d111139

File tree

1 file changed

+308
-0
lines changed

1 file changed

+308
-0
lines changed
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
### Documentation TODO
2+
3+
- Update C# examples when it is implemented
4+
- Update the rust examples one last time when auth claims are merged
5+
6+
I asked Jeff:
7+
8+
Can auth claims change after a user has already authenticated? Like if I connect a client which authenticates via SpacetimeAuth but then after they connect they change their phone number or address or something else that would be in their auth claims - when will the module see that change?
9+
10+
^ Update documentation for the response to this
11+
12+
13+
# Accessing Auth Claims in Modules
14+
15+
SpacetimeDB allows you to easily access authentication (auth) claims embedded in OIDC-compliant JWT tokens. Auth claims are key-value pairs that provide information about the authenticated user. For example, they may contain a user's unique ID, email, or authentication provider. If you want to view these fields for yourself, you can inspect the contents of any JWT using online tools like [jwt.io](https://jwt.io/).
16+
17+
In a SpacetimeDB module, auth claims from a client's token are accessible via the `ReducerContext` which is passed to all reducers. The following examples show how to access and use these claims in your module code.
18+
19+
## Accessing Common Claims: Subject and Issuer
20+
21+
The subject (`sub`) and issuer (`iss`) are the most commonly accessed claims in a JWT. The subject usually represents the user's unique identifier, while the issuer indicates which authentication provider issued the token.
22+
23+
Below are examples of how to access these claims in both Rust and C# modules:
24+
25+
:::server-rust
26+
27+
```rust
28+
#[reducer(client_connected)]
29+
pub fn connect(ctx: &ReducerContext) -> Result<(), String> {
30+
let auth_ctx = ctx.sender_auth();
31+
let (subject, issuer) = match auth_ctx.jwt() {
32+
Some(claims) => (claims.subject().to_string(), claims.issuer().to_string()),
33+
None => {
34+
return Err("Client connected without JWT".to_string());
35+
}
36+
};
37+
log::info!("sub: {}, iss: {}", subject, issuer);
38+
Ok(())
39+
}
40+
```
41+
42+
*Example output when using a Google-issued token:*
43+
```
44+
INFO: src\lib.rs:64: sub: 321321321321321, iss: https://accounts.google.com
45+
```
46+
:::
47+
:::server-csharp
48+
49+
```cs
50+
// ************************TODO: update + test this after Jeff implements this in C# ************************
51+
[Reducer(ReducerKind.ClientConnected)]
52+
public void Connect(ReducerContext ctx) {
53+
var auth_ctx = ctx.SenderAuth();
54+
var (subject, issuer) = auth_ctx.Jwt() switch {
55+
Some(var claims) => (claims.Subject, claims.Issuer),
56+
None => throw new Exception("Client connected without JWT"),
57+
};
58+
log.Info($"sub: {subject}, iss: {issuer}");
59+
}
60+
```
61+
62+
*Example output when using a Google-issued token:*
63+
```
64+
INFO: src\Lib.cs:64: sub: 321321321321321, iss: https://accounts.google.com
65+
```
66+
:::
67+
68+
## Accessing Custom Claims
69+
70+
If you want to access additional claims that are not parsed by default by SpacetimeDB, you can parse the raw JWT payload yourself. This is useful for handling custom or application-specific claims.
71+
72+
Below is a Rust example for extracting a custom claim (e.g., email):
73+
74+
:::server-rust
75+
76+
If you'd like to compile this example, please start with modifying your Cargo.toml to include the following dependencies:
77+
78+
```toml
79+
[dependencies]
80+
...
81+
82+
log = "0.4"
83+
serde = { version = "1.0.219", features = ["derive"] }
84+
serde_json = "1.0.143"
85+
```
86+
87+
```rust
88+
use spacetimedb::{reducer, table, ReducerContext, Table};
89+
90+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
91+
pub struct CustomClaims {
92+
pub iss: String,
93+
pub sub: String,
94+
pub iat: u64,
95+
pub email: String,
96+
}
97+
98+
#[table(name = user)]
99+
pub struct User {
100+
#[primary_key]
101+
#[auto_inc]
102+
pub id: u64,
103+
#[unique]
104+
pub email: String,
105+
}
106+
107+
#[reducer(client_connected)]
108+
pub fn connect(ctx: &ReducerContext) -> Result<(), String> {
109+
let auth_ctx = ctx.sender_auth();
110+
let payload = auth_ctx.jwt().unwrap().raw_payload();
111+
let claims: CustomClaims = serde_json::from_slice(payload.as_bytes()).map_err(|e| format!("Client connected with invalid JWT: {}", e).to_string())?;
112+
113+
// In this example, we'll identify users based on their email
114+
if ctx.db.user().email().find(&claims.email).is_none() {
115+
ctx.db.user().insert(User {
116+
id: 0,
117+
email: claims.email.clone(),
118+
});
119+
log::info!("Created new user with email: {}.", claims.email);
120+
} else {
121+
log::info!("User with email {} has returned.", claims.email);
122+
}
123+
124+
Ok(())
125+
}
126+
```
127+
:::
128+
:::server-csharp
129+
```cs
130+
// ************************TODO: update + test this after Jeff implements this in C# ************************
131+
[Reducer(ReducerKind.ClientConnected)]
132+
public void Connect(ReducerContext ctx) {
133+
var auth_ctx = ctx.SenderAuth();
134+
var jwt = auth_ctx.Jwt().Value;
135+
var custom_claim = jwt.Claims["custom_claim"].Value;
136+
}
137+
```
138+
:::
139+
140+
141+
## Example: Restricting Accepted Issuers
142+
143+
Since users can use any valid token to connect to SpacetimeDB, their token may originate from any authentication provider. For example, they could send an OIDC compliant token from Github even though you only want to accept tokens from Google. If you want to only allow users to authenticate using a specific provider (e.g., Google), you can check the issuer claim `iss` as shown below:
144+
145+
:::server-rust
146+
147+
```rust
148+
#[reducer(client_connected)]
149+
pub fn connect(ctx: &ReducerContext) -> Result<(), String> {
150+
let auth_ctx = ctx.sender_auth();
151+
if auth_ctx.jwt().is_none() {
152+
return Err("Client connected without JWT".to_string());
153+
}
154+
155+
let jwt = auth_ctx.jwt().unwrap();
156+
// Example: We only accept google auth
157+
if jwt.issuer() != "https://accounts.google.com" {
158+
return Err(format!("Client connected with a JWT from an unaccepted issuer: {}", jwt.issuer()));
159+
}
160+
161+
Ok(())
162+
}
163+
```
164+
:::
165+
:::server-csharp
166+
```cs
167+
[Reducer(ReducerKind.ClientConnected)]
168+
public void Connect(ReducerContext ctx) {
169+
var authContext = ctx.SenderAuth();
170+
if (authContext.Jwt() == null) {
171+
throw new Exception("Client connected without JWT");
172+
}
173+
174+
// Example: We only accept google auth
175+
var jwt = authContext.Jwt();
176+
if (jwt.Issuer != "https://accounts.google.com") {
177+
throw new Exception($"Client connected with a JWT from an unaccepted issuer: {jwt.Issuer}");
178+
}
179+
}
180+
```
181+
:::
182+
183+
> **Important:** If you return an error from the connect reducer, the client will be disconnected immediately.
184+
185+
## Example: automatically give users a role based on their auth claims
186+
187+
When users first connect to your SpacetimeDB module you may want to automatically give them some role. For example, at clockwork we might want to give users with a valid "clockworklabs.io" email an admin role. We can do this by checking the email claim in the connect reducer and giving them the admin role if they have a valid email.
188+
189+
:::server-rust
190+
191+
```rust
192+
use spacetimedb::{reducer, table, ReducerContext, SpacetimeType, Table};
193+
194+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
195+
pub struct CustomClaims {
196+
pub iss: String,
197+
pub sub: String,
198+
pub iat: u64,
199+
pub email: String,
200+
}
201+
202+
#[table(name = user)]
203+
pub struct User {
204+
#[primary_key]
205+
#[auto_inc]
206+
pub id: u64,
207+
#[unique]
208+
pub email: String,
209+
}
210+
211+
#[derive(SpacetimeType)]
212+
pub enum Role {
213+
User,
214+
Admin,
215+
}
216+
217+
#[table(name = user_role)]
218+
pub struct UserRole {
219+
#[primary_key]
220+
pub id: u64,
221+
pub role: Role,
222+
}
223+
224+
225+
#[reducer(client_connected)]
226+
pub fn connect(ctx: &ReducerContext) -> Result<(), String> {
227+
let auth_ctx = ctx.sender_auth();
228+
let payload = auth_ctx.jwt().unwrap().raw_payload();
229+
let claims: CustomClaims = serde_json::from_slice(payload.as_bytes()).map_err(|e| format!("Client connected with invalid JWT: {}", e).to_string())?;
230+
231+
// In this example you would really want to check the issuer of the JWT to ensure it is from a trusted provider
232+
if auth_ctx.jwt().unwrap().issuer() != "https://accounts.google.com" {
233+
return Err("Client connected with a JWT from an unaccepted issuer".to_string());
234+
}
235+
236+
if ctx.db.user().email().find(&claims.email).is_none() {
237+
let user = ctx.db.user().insert(User {
238+
id: 0,
239+
email: claims.email.clone(),
240+
});
241+
242+
if claims.email.ends_with("clockworklabs.io") {
243+
ctx.db.user_role().insert(UserRole {
244+
id: user.id,
245+
role: Role::Admin,
246+
});
247+
log::info!("Created new admin user with email: {}.", claims.email);
248+
} else {
249+
log::info!("Created new normal user with email: {}.", claims.email);
250+
}
251+
252+
} else {
253+
log::info!("User with email {} has returned.", claims.email);
254+
}
255+
256+
Ok(())
257+
}
258+
```
259+
:::
260+
:::server-csharp
261+
```cs
262+
// ************************TODO: update + test this after Jeff implements this in C# ************************
263+
[Reducer(ReducerKind.ClientConnected)]
264+
public void Connect(ReducerContext ctx) {
265+
var authContext = ctx.SenderAuth();
266+
if (authContext.Jwt() == null) {
267+
throw new Exception("Client connected without JWT");
268+
}
269+
270+
var jwt = authContext.Jwt();
271+
// In this example you would really want to check the issuer of the JWT to ensure it is from a trusted provider
272+
if (jwt.Issuer != "https://accounts.google.com") {
273+
throw new Exception("Client connected with a JWT from an unaccepted issuer");
274+
}
275+
276+
var email = jwt.Claims.FirstOrDefault(c => c.Type == "email")?.Value;
277+
if (ctx.Db.User().Email().Find(email) == null) {
278+
var user = ctx.Db.User().Insert(new User {
279+
Id = 0,
280+
Email = email
281+
});
282+
283+
if (email.EndsWith("@clockworklabs.io")) {
284+
ctx.Db.UserRole().Insert(new UserRole {
285+
Id = user.Id,
286+
Role = Role.Admin
287+
});
288+
Serilog.Log.Information("Created new admin user with email: {Email}.", email);
289+
} else {
290+
Serilog.Log.Information("Created new normal user with email: {Email}.", email);
291+
}
292+
293+
} else {
294+
Serilog.Log.Information("User with email {Email} has returned.", email);
295+
}
296+
}
297+
```
298+
:::
299+
300+
---
301+
302+
## Summary & Best Practices
303+
304+
- Always validate the presence and contents of JWT claims before trusting them in your application logic.
305+
- For custom application logic, deserialize the JWT payload to access additional claims which are not parsed by default.
306+
- Restrict accepted issuers where appropriate to enforce security policies.
307+
308+
For more information, refer to the [SpacetimeDB documentation](https://spacetimedb.com/docs/) or reach out to the SpacetimeDB community for help.

0 commit comments

Comments
 (0)