Skip to content

Commit 1d08167

Browse files
authored
Store client credentials in a new system table (#2983)
# Description of Changes This adds a new system table to store the jwt payloads of connected clients. I'm planning to use this system table to expose client claims to modules in subsequent PRs. The new table is called `st_connection_credentials`. It is a **private** system table which stores a mapping from `connection_id` to `jwt_payload`. Note that a jwt payload is a json representation of the clients claims, not a fully signed token. The times when we need to insert and delete these rows closely mirrors that of the existing `st_client` table, with 1.5 exceptions: 1. We weren't previously inserting to `st_client` until after the `OnConnect` reducer ran (even though it was in the same transaction). We want `st_connection_credentials` to be populated before calling the reducer, so that the reducer can use it get the credentials, so I made a change to insert to `st_client` and `st_connection_credentials` before calling the reducer. 2. This difference has not actualized, but when clients start sending refresh tokens, we will probably need to update the credentials stored in this table. This also enforces uniqueness of connection ids. A duplicate connection id will now make the on-connect reducer fail (since it will violate uniqueness when trying to insert to `st_connection_credentials`). # Expected complexity level and risk 2.5 Adding a system table is a bit risky. This is almost rollback safe, with one annoying case that is worth calling out: If a database is created with this system table, opening it with an older version of spacetimedb will only work if there is a snapshot of the database. If we try to load a table without a snapshot, replaying will fail on the first row for that table. This is because we don't write the table schema information to the commit log when creating a database. In practice, this is unlikely to be an issue, because new databases asynchronously trigger a snapshot immediately after creation. Migrating existing databases will be fine. On startup this will detect that there is a missing system table, and add it in a way that writes it to the commit log. Since it is in the commit log, we can open the database with an older version and still understand the data for that table. # Testing There are unit tests that cover opening a database created with an older version (which doesn't have this table). I manually tested opening a migrated database with an older version of spacetimedb.
1 parent 2f9554c commit 1d08167

File tree

28 files changed

+595
-188
lines changed

28 files changed

+595
-188
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,3 +216,6 @@ new.json
216216
# Keys
217217
*.pem
218218
.ok.sql
219+
220+
# Test data
221+
!crates/core/testdata/

Cargo.lock

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

crates/auth/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ spacetimedb-lib = { workspace = true, features = ["serde"] }
1111

1212
anyhow.workspace = true
1313
serde.workspace = true
14+
serde_json.workspace = true
1415
serde_with.workspace = true
1516
jsonwebtoken.workspace = true
1617

crates/auth/src/identity.rs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,27 @@ use serde::{Deserialize, Serialize};
66
use spacetimedb_lib::Identity;
77
use std::time::SystemTime;
88

9+
#[derive(Debug, Clone)]
10+
pub struct ConnectionAuthCtx {
11+
pub claims: SpacetimeIdentityClaims,
12+
pub jwt_payload: String,
13+
}
14+
15+
impl TryFrom<SpacetimeIdentityClaims> for ConnectionAuthCtx {
16+
type Error = anyhow::Error;
17+
fn try_from(claims: SpacetimeIdentityClaims) -> Result<Self, Self::Error> {
18+
let payload =
19+
serde_json::to_string(&claims).map_err(|e| anyhow::anyhow!("Failed to serialize claims: {}", e))?;
20+
Ok(ConnectionAuthCtx {
21+
claims,
22+
jwt_payload: payload,
23+
})
24+
}
25+
}
26+
927
// These are the claims that can be attached to a request/connection.
1028
#[serde_with::serde_as]
11-
#[derive(Debug, Serialize, Deserialize)]
29+
#[derive(Debug, Serialize, Deserialize, Clone)]
1230
pub struct SpacetimeIdentityClaims {
1331
#[serde(rename = "hex_identity")]
1432
pub identity: Identity,

crates/client-api/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ spacetimedb-lib = { workspace = true, features = ["serde"] }
1515
spacetimedb-paths.workspace = true
1616
spacetimedb-schema.workspace = true
1717

18+
base64.workspace = true
1819
tokio = { version = "1.2", features = ["full"] }
1920
lazy_static = "1.4.0"
2021
log = "0.4.4"

crates/client-api/src/auth.rs

Lines changed: 110 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,24 @@
1-
use std::time::{Duration, SystemTime};
2-
1+
use anyhow::anyhow;
32
use axum::extract::{Query, Request, State};
43
use axum::middleware::Next;
54
use axum::response::IntoResponse;
65
use axum_extra::typed_header::TypedHeader;
76
use headers::{authorization, HeaderMapExt};
87
use http::{request, HeaderValue, StatusCode};
98
use serde::{Deserialize, Serialize};
10-
use spacetimedb::auth::identity::SpacetimeIdentityClaims;
9+
use spacetimedb::auth::identity::{ConnectionAuthCtx, SpacetimeIdentityClaims};
1110
use spacetimedb::auth::identity::{JwtError, JwtErrorKind};
1211
use spacetimedb::auth::token_validation::{
1312
new_validator, DefaultValidator, TokenSigner, TokenValidationError, TokenValidator,
1413
};
1514
use spacetimedb::auth::JwtKeys;
1615
use spacetimedb::energy::EnergyQuanta;
1716
use spacetimedb::identity::Identity;
17+
use std::time::{Duration, SystemTime};
1818
use uuid::Uuid;
1919

2020
use crate::{log_and_500, ControlStateDelegate, NodeDelegate};
21+
use base64::{engine::general_purpose, Engine};
2122

2223
/// Credentials for login for a spacetime identity, represented as a JWT.
2324
///
@@ -41,6 +42,19 @@ impl SpacetimeCreds {
4142
Self { token }
4243
}
4344

45+
fn extract_jwt_payload_string(&self) -> Option<String> {
46+
let parts: Vec<&str> = self.token.split('.').collect();
47+
if parts.len() != 3 {
48+
return None;
49+
}
50+
51+
let payload_encoded = parts[1];
52+
let decoded_bytes = general_purpose::URL_SAFE_NO_PAD.decode(payload_encoded).ok()?;
53+
let json_str = String::from_utf8(decoded_bytes).ok()?;
54+
55+
Some(json_str)
56+
}
57+
4458
pub fn to_header_value(&self) -> HeaderValue {
4559
let mut val = HeaderValue::try_from(["Bearer ", self.token()].concat()).unwrap();
4660
val.set_sensitive(true);
@@ -70,9 +84,31 @@ impl SpacetimeCreds {
7084
#[derive(Clone)]
7185
pub struct SpacetimeAuth {
7286
pub creds: SpacetimeCreds,
73-
pub identity: Identity,
74-
pub subject: String,
75-
pub issuer: String,
87+
pub claims: SpacetimeIdentityClaims,
88+
/// The JWT payload as a json string (after base64 decoding).
89+
pub jwt_payload: String,
90+
}
91+
92+
impl SpacetimeAuth {
93+
pub fn new(creds: SpacetimeCreds, claims: SpacetimeIdentityClaims) -> Result<Self, anyhow::Error> {
94+
let payload = creds
95+
.extract_jwt_payload_string()
96+
.ok_or_else(|| anyhow!("Failed to extract JWT payload"))?;
97+
Ok(Self {
98+
creds,
99+
claims,
100+
jwt_payload: payload,
101+
})
102+
}
103+
}
104+
105+
impl From<SpacetimeAuth> for ConnectionAuthCtx {
106+
fn from(auth: SpacetimeAuth) -> Self {
107+
ConnectionAuthCtx {
108+
claims: auth.claims,
109+
jwt_payload: auth.jwt_payload.clone(),
110+
}
111+
}
76112
}
77113

78114
use jsonwebtoken;
@@ -84,10 +120,10 @@ pub struct TokenClaims {
84120
}
85121

86122
impl From<SpacetimeAuth> for TokenClaims {
87-
fn from(claims: SpacetimeAuth) -> Self {
123+
fn from(auth: SpacetimeAuth) -> Self {
88124
Self {
89-
issuer: claims.issuer,
90-
subject: claims.subject,
125+
issuer: auth.claims.issuer,
126+
subject: auth.claims.subject,
91127
// This will need to be changed when we care about audiencies.
92128
audience: Vec::new(),
93129
}
@@ -108,11 +144,14 @@ impl TokenClaims {
108144
Identity::from_claims(&self.issuer, &self.subject)
109145
}
110146

147+
/// Encode the claims into a JWT token and sign it with the provided signer.
148+
/// This also adds claims for expiry and issued at time.
149+
/// Returns an object representing the claims and the signed token.
111150
pub fn encode_and_sign_with_expiry(
112151
&self,
113152
signer: &impl TokenSigner,
114153
expiry: Option<Duration>,
115-
) -> Result<String, JwtError> {
154+
) -> Result<(SpacetimeIdentityClaims, String), JwtError> {
116155
let iat = SystemTime::now();
117156
let exp = expiry.map(|dur| iat + dur);
118157
let claims = SpacetimeIdentityClaims {
@@ -123,10 +162,14 @@ impl TokenClaims {
123162
iat,
124163
exp,
125164
};
126-
signer.sign(&claims)
165+
let token = signer.sign(&claims)?;
166+
Ok((claims, token))
127167
}
128168

129-
pub fn encode_and_sign(&self, signer: &impl TokenSigner) -> Result<String, JwtError> {
169+
/// Encode the claims into a JWT token and sign it with the provided signer.
170+
/// This also adds a claim for issued at time.
171+
/// Returns an object representing the claims and the signed token.
172+
pub fn encode_and_sign(&self, signer: &impl TokenSigner) -> Result<(SpacetimeIdentityClaims, String), JwtError> {
130173
self.encode_and_sign_with_expiry(signer, None)
131174
}
132175
}
@@ -143,32 +186,28 @@ impl SpacetimeAuth {
143186
audience: vec!["spacetimedb".to_string()],
144187
};
145188

146-
let identity = claims.id();
147-
let creds = {
148-
let token = claims.encode_and_sign(ctx.jwt_auth_provider()).map_err(log_and_500)?;
149-
SpacetimeCreds::from_signed_token(token)
150-
};
151-
152-
Ok(Self {
153-
creds,
154-
identity,
155-
subject,
156-
issuer: ctx.jwt_auth_provider().local_issuer().to_string(),
157-
})
189+
let (claims, token) = claims.encode_and_sign(ctx.jwt_auth_provider()).map_err(log_and_500)?;
190+
let creds = SpacetimeCreds::from_signed_token(token);
191+
// Pulling out the payload should never fail, since we just made it.
192+
Self::new(creds, claims).map_err(log_and_500)
158193
}
159194

160195
/// Get the auth credentials as headers to be returned from an endpoint.
161196
pub fn into_headers(self) -> (TypedHeader<SpacetimeIdentity>, TypedHeader<SpacetimeIdentityToken>) {
162197
(
163-
TypedHeader(SpacetimeIdentity(self.identity)),
198+
TypedHeader(SpacetimeIdentity(self.claims.identity)),
164199
TypedHeader(SpacetimeIdentityToken(self.creds)),
165200
)
166201
}
167202

168203
// Sign a new token with the same claims and a new expiry.
169204
// Note that this will not change the issuer, so the private_key might not match.
170205
// We do this to create short-lived tokens that we will be able to verify.
171-
pub fn re_sign_with_expiry(&self, signer: &impl TokenSigner, expiry: Duration) -> Result<String, JwtError> {
206+
pub fn re_sign_with_expiry(
207+
&self,
208+
signer: &impl TokenSigner,
209+
expiry: Duration,
210+
) -> Result<(SpacetimeIdentityClaims, String), JwtError> {
172211
TokenClaims::from(self.clone()).encode_and_sign_with_expiry(signer, Some(expiry))
173212
}
174213
}
@@ -237,9 +276,11 @@ impl<TV: TokenValidator + Send + Sync> JwtAuthProvider for JwtKeyAuthProvider<TV
237276

238277
#[cfg(test)]
239278
mod tests {
240-
use crate::auth::TokenClaims;
279+
use crate::auth::{SpacetimeCreds, TokenClaims};
241280
use anyhow::Ok;
281+
242282
use spacetimedb::auth::{token_validation::TokenValidator, JwtKeys};
283+
use std::collections::HashSet;
243284

244285
// Make sure that when we encode TokenClaims, we can decode to get the expected identity.
245286
#[tokio::test]
@@ -252,12 +293,48 @@ mod tests {
252293
audience: vec!["spacetimedb".to_string()],
253294
};
254295
let id = claims.id();
255-
let token = claims.encode_and_sign(&kp.private)?;
296+
let (_, token) = claims.encode_and_sign(&kp.private)?;
256297
let decoded = kp.public.validate_token(&token).await?;
257298

258299
assert_eq!(decoded.identity, id);
259300
Ok(())
260301
}
302+
303+
// Test that extracting a JWT payload from a valid token gets the json representation.
304+
#[tokio::test]
305+
async fn extract_payload() -> Result<(), anyhow::Error> {
306+
let kp = JwtKeys::generate()?;
307+
308+
let dummy_audience = "spacetimedb".to_string();
309+
let claims = TokenClaims {
310+
issuer: "localhost".to_string(),
311+
subject: "test-subject".to_string(),
312+
audience: vec![dummy_audience.clone()],
313+
};
314+
let (_, token) = claims.encode_and_sign(&kp.private)?;
315+
let st_creds = SpacetimeCreds::from_signed_token(token);
316+
let payload = st_creds
317+
.extract_jwt_payload_string()
318+
.ok_or_else(|| anyhow::anyhow!("Failed to extract JWT payload"))?;
319+
// Make sure it is valid json.
320+
let parsed: serde_json::Value = serde_json::from_str(&payload)?;
321+
assert_eq!(parsed.get("iss").unwrap().as_str().unwrap(), claims.issuer);
322+
assert_eq!(parsed.get("sub").unwrap().as_str().unwrap(), claims.subject);
323+
assert_eq!(
324+
parsed.get("aud").unwrap().as_array().unwrap()[0].as_str().unwrap(),
325+
dummy_audience
326+
);
327+
let as_object = parsed
328+
.as_object()
329+
.ok_or_else(|| anyhow::anyhow!("Failed to parse JWT payload as object"))?;
330+
let keys: HashSet<String> = as_object.keys().map(|s| s.to_string()).collect();
331+
let expected_keys = vec!["iss", "sub", "aud", "iat", "exp", "hex_identity"]
332+
.into_iter()
333+
.map(|s| s.to_string())
334+
.collect::<HashSet<String>>();
335+
assert_eq!(keys, expected_keys);
336+
Ok(())
337+
}
261338
}
262339

263340
pub async fn validate_token<S: NodeDelegate>(
@@ -283,11 +360,13 @@ impl<S: NodeDelegate + Send + Sync> axum::extract::FromRequestParts<S> for Space
283360
.await
284361
.map_err(AuthorizationRejection::Custom)?;
285362

363+
let payload = creds.extract_jwt_payload_string().ok_or_else(|| {
364+
AuthorizationRejection::Custom(TokenValidationError::Other(anyhow!("Internal error parsing token")))
365+
})?;
286366
let auth = SpacetimeAuth {
287367
creds,
288-
identity: claims.identity,
289-
subject: claims.subject,
290-
issuer: claims.issuer,
368+
claims,
369+
jwt_payload: payload,
291370
};
292371
Ok(Self { auth: Some(auth) })
293372
}

0 commit comments

Comments
 (0)