Skip to content

Commit e44dd2c

Browse files
authored
Merge branch 'master' into jlarabie/unreal-sdk-initial
2 parents c7d2fc8 + d0d99b9 commit e44dd2c

30 files changed

Lines changed: 628 additions & 196 deletions

File tree

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