Skip to content

Commit 3f3b225

Browse files
committed
Add AuthCtx to the ReducerContext
1 parent 1decd97 commit 3f3b225

4 files changed

Lines changed: 171 additions & 10 deletions

File tree

Cargo.lock

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/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ rand08 = { workspace = true, optional = true }
3535
# if someone tries to use rand's ThreadRng, it will fail to link
3636
# because no one defined __getrandom_custom
3737
getrandom02 = { workspace = true, optional = true, features = ["custom"] }
38+
serde_json.workspace = true
39+
once_cell = "1.21.3"
3840

3941
[dev-dependencies]
4042
insta.workspace = true

crates/bindings/src/lib.rs

Lines changed: 166 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,13 @@ pub mod rt;
1212
#[doc(hidden)]
1313
pub mod table;
1414

15-
use spacetimedb_lib::bsatn;
16-
use std::cell::RefCell;
17-
1815
pub use log;
1916
#[cfg(feature = "rand")]
2017
pub use rand08 as rand;
18+
use spacetimedb_lib::bsatn;
19+
use std::cell::{OnceCell, RefCell};
20+
use std::ops::Deref;
21+
use std::sync::LazyLock;
2122

2223
#[cfg(feature = "unstable")]
2324
pub use client_visibility_filter::Filter;
@@ -732,6 +733,8 @@ pub struct ReducerContext {
732733
/// See the [`#[table]`](macro@crate::table) macro for more information.
733734
pub db: Local,
734735

736+
sender_auth: AuthCtx,
737+
735738
#[cfg(feature = "rand08")]
736739
rng: std::cell::OnceCell<StdbRng>,
737740
}
@@ -744,6 +747,24 @@ impl ReducerContext {
744747
sender: Identity::__dummy(),
745748
timestamp: Timestamp::UNIX_EPOCH,
746749
connection_id: None,
750+
sender_auth: AuthCtx::internal(),
751+
rng: std::cell::OnceCell::new(),
752+
}
753+
}
754+
755+
#[doc(hidden)]
756+
fn new(db: Local, sender: Identity, connection_id: Option<ConnectionId>, timestamp: Timestamp) -> Self {
757+
let sender_auth = match connection_id {
758+
Some(cid) => AuthCtx::from_connection_id(cid),
759+
None => AuthCtx::internal(),
760+
};
761+
Self {
762+
db,
763+
sender,
764+
timestamp,
765+
connection_id,
766+
sender_auth,
767+
#[cfg(feature = "rand08")]
747768
rng: std::cell::OnceCell::new(),
748769
}
749770
}
@@ -796,6 +817,114 @@ impl DbContext for ReducerContext {
796817
#[non_exhaustive]
797818
pub struct Local {}
798819

820+
#[non_exhaustive]
821+
pub struct JwtClaims {
822+
payload: String,
823+
parsed: OnceCell<serde_json::Value>,
824+
audience: OnceCell<Vec<String>>,
825+
}
826+
827+
/// Authentication information for the caller of a reducer.
828+
pub struct AuthCtx {
829+
is_internal: bool,
830+
// I can't directly use a LazyLock without making this struct generic.
831+
jwt: Box<dyn Deref<Target = Option<JwtClaims>>>,
832+
}
833+
834+
impl AuthCtx {
835+
fn new<F>(is_internal: bool, jwt_fn: F) -> Self
836+
where
837+
F: FnOnce() -> Option<JwtClaims> + 'static,
838+
{
839+
AuthCtx {
840+
is_internal,
841+
jwt: Box::new(LazyLock::new(jwt_fn)),
842+
}
843+
}
844+
845+
/// Create an AuthCtx for an internal call, with no JWT.
846+
/// This represents a scheduled reducer.
847+
pub fn internal() -> AuthCtx {
848+
Self::new(true, || None)
849+
}
850+
851+
/// Create an AuthCtx using the json claims from a JWT.
852+
/// This can be used to write unit tests.
853+
pub fn from_jwt_payload(jwt_payload: String) -> AuthCtx {
854+
Self::new(false, move || Some(JwtClaims::new(jwt_payload)))
855+
}
856+
857+
/// Create an AuthCtx that reads the JWT for the given connection id.
858+
fn from_connection_id(connection_id: ConnectionId) -> AuthCtx {
859+
Self::new(false, move || {
860+
spacetimedb_bindings_sys::get_jwt(connection_id.as_le_byte_array()).map(JwtClaims::new)
861+
})
862+
}
863+
864+
/// True if this reducer was spawned from inside the database.
865+
pub fn is_internal(&self) -> bool {
866+
self.is_internal
867+
}
868+
869+
/// Check if there is a JWT without loading it.
870+
/// If is_internal is true, this will be false.
871+
pub fn has_jwt(&self) -> bool {
872+
self.jwt.is_some()
873+
}
874+
875+
/// Load the jwt.
876+
pub fn jwt(&self) -> Option<&JwtClaims> {
877+
self.jwt.as_ref().deref().as_ref()
878+
}
879+
}
880+
881+
impl JwtClaims {
882+
fn new(jwt: String) -> Self {
883+
Self {
884+
payload: jwt,
885+
parsed: OnceCell::new(),
886+
audience: OnceCell::new(),
887+
}
888+
}
889+
890+
fn get_parsed(&self) -> &serde_json::Value {
891+
self.parsed
892+
.get_or_init(|| serde_json::from_str(&self.payload).expect("Failed to parse JWT payload"))
893+
}
894+
895+
pub fn subject(&self) -> &str {
896+
// TODO: Add more error messages here.
897+
self.get_parsed().get("sub").unwrap().as_str().unwrap()
898+
}
899+
900+
pub fn issuer(&self) -> &str {
901+
self.get_parsed().get("iss").unwrap().as_str().unwrap()
902+
}
903+
904+
fn extract_audience(&self) -> Vec<String> {
905+
let aud = self.get_parsed().get("aud").unwrap();
906+
match aud {
907+
serde_json::Value::String(s) => vec![s.clone()],
908+
serde_json::Value::Array(arr) => arr.iter().filter_map(|v| v.as_str().map(String::from)).collect(),
909+
_ => panic!("Unexpected type for 'aud' claim in JWT"),
910+
}
911+
}
912+
913+
pub fn audience(&self) -> &[String] {
914+
self.audience.get_or_init(|| self.extract_audience())
915+
}
916+
917+
// A convenience method, since this may not be in the token.
918+
pub fn identity(&self) -> Identity {
919+
Identity::from_claims(self.issuer(), self.subject())
920+
}
921+
922+
// We can expose the whole payload for users that want to parse custom claims.
923+
pub fn raw_payload(&self) -> &str {
924+
&self.payload
925+
}
926+
}
927+
799928
// #[cfg(target_arch = "wasm32")]
800929
// #[global_allocator]
801930
// static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
@@ -900,3 +1029,37 @@ macro_rules! __volatile_nonatomic_schedule_immediate_impl {
9001029
}
9011030
};
9021031
}
1032+
1033+
#[cfg(test)]
1034+
mod tests {
1035+
use super::*;
1036+
1037+
#[test]
1038+
fn parse_single_audience() {
1039+
let example_payload = r#"
1040+
{
1041+
"iss": "https://securetoken.google.com/my-project-id",
1042+
"aud": "my-project-id",
1043+
"auth_time": 1695560000,
1044+
"user_id": "abc123XYZ",
1045+
"sub": "abc123XYZ",
1046+
"iat": 1695560100,
1047+
"exp": 1695563700,
1048+
"email": "user@example.com",
1049+
"email_verified": true,
1050+
"firebase": {
1051+
"identities": {
1052+
"email": ["user@example.com"]
1053+
},
1054+
"sign_in_provider": "password"
1055+
},
1056+
"name": "Jane Doe",
1057+
"picture": "https://lh3.googleusercontent.com/a-/profile.jpg"
1058+
}
1059+
"#;
1060+
let auth = AuthCtx::from_jwt_payload(example_payload.to_string());
1061+
let audience = auth.jwt().unwrap().audience();
1062+
assert_eq!(audience.len(), 1);
1063+
assert_eq!(audience, &["my-project-id".to_string()]);
1064+
}
1065+
}

crates/bindings/src/rt.rs

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -488,13 +488,7 @@ extern "C" fn __call_reducer__(
488488

489489
// Assemble the `ReducerContext`.
490490
let timestamp = Timestamp::from_micros_since_unix_epoch(timestamp as i64);
491-
let ctx = ReducerContext {
492-
db: crate::Local {},
493-
sender,
494-
timestamp,
495-
connection_id: conn_id,
496-
rng: std::cell::OnceCell::new(),
497-
};
491+
let ctx = ReducerContext::new(crate::Local {}, sender, conn_id, timestamp);
498492

499493
// Fetch reducer function.
500494
let reducers = REDUCERS.get().unwrap();

0 commit comments

Comments
 (0)