|
| 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