-
Notifications
You must be signed in to change notification settings - Fork 608
Add AuthCtx to ReducerContext for rust #3288
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
1decd97
3f3b225
61ffd83
58b5f26
70af0ae
57a024f
f7042cb
bdff86c
bfdc636
a89b883
7c4da19
fba36f1
0f61291
0a274d3
7d872c5
291c20b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change | ||
---|---|---|---|---|
|
@@ -35,6 +35,8 @@ rand08 = { workspace = true, optional = true } | |||
# if someone tries to use rand's ThreadRng, it will fail to link | ||||
# because no one defined __getrandom_custom | ||||
getrandom02 = { workspace = true, optional = true, features = ["custom"] } | ||||
serde_json.workspace = true | ||||
Centril marked this conversation as resolved.
Show resolved
Hide resolved
|
||||
once_cell = "1.21.3" | ||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is part of the standard library now.
Suggested change
|
||||
|
||||
[dev-dependencies] | ||||
insta.workspace = true | ||||
|
Original file line number | Diff line number | Diff line change | ||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -12,12 +12,13 @@ pub mod rt; | |||||||||||||||||
#[doc(hidden)] | ||||||||||||||||||
pub mod table; | ||||||||||||||||||
|
||||||||||||||||||
use spacetimedb_lib::bsatn; | ||||||||||||||||||
use std::cell::RefCell; | ||||||||||||||||||
|
||||||||||||||||||
pub use log; | ||||||||||||||||||
#[cfg(feature = "rand")] | ||||||||||||||||||
pub use rand08 as rand; | ||||||||||||||||||
use spacetimedb_lib::bsatn; | ||||||||||||||||||
use std::cell::{OnceCell, RefCell}; | ||||||||||||||||||
use std::ops::Deref; | ||||||||||||||||||
use std::sync::LazyLock; | ||||||||||||||||||
|
||||||||||||||||||
#[cfg(feature = "unstable")] | ||||||||||||||||||
pub use client_visibility_filter::Filter; | ||||||||||||||||||
|
@@ -732,6 +733,8 @@ pub struct ReducerContext { | |||||||||||||||||
/// See the [`#[table]`](macro@crate::table) macro for more information. | ||||||||||||||||||
pub db: Local, | ||||||||||||||||||
|
||||||||||||||||||
sender_auth: AuthCtx, | ||||||||||||||||||
|
||||||||||||||||||
#[cfg(feature = "rand08")] | ||||||||||||||||||
rng: std::cell::OnceCell<StdbRng>, | ||||||||||||||||||
} | ||||||||||||||||||
|
@@ -744,10 +747,32 @@ impl ReducerContext { | |||||||||||||||||
sender: Identity::__dummy(), | ||||||||||||||||||
timestamp: Timestamp::UNIX_EPOCH, | ||||||||||||||||||
connection_id: None, | ||||||||||||||||||
sender_auth: AuthCtx::internal(), | ||||||||||||||||||
rng: std::cell::OnceCell::new(), | ||||||||||||||||||
} | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
#[doc(hidden)] | ||||||||||||||||||
fn new(db: Local, sender: Identity, connection_id: Option<ConnectionId>, timestamp: Timestamp) -> Self { | ||||||||||||||||||
let sender_auth = match connection_id { | ||||||||||||||||||
Some(cid) => AuthCtx::from_connection_id(cid), | ||||||||||||||||||
None => AuthCtx::internal(), | ||||||||||||||||||
}; | ||||||||||||||||||
Self { | ||||||||||||||||||
db, | ||||||||||||||||||
sender, | ||||||||||||||||||
timestamp, | ||||||||||||||||||
connection_id, | ||||||||||||||||||
sender_auth, | ||||||||||||||||||
#[cfg(feature = "rand08")] | ||||||||||||||||||
rng: std::cell::OnceCell::new(), | ||||||||||||||||||
} | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
pub fn sender_auth(&self) -> &AuthCtx { | ||||||||||||||||||
&self.sender_auth | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
/// Read the current module's [`Identity`]. | ||||||||||||||||||
pub fn identity(&self) -> Identity { | ||||||||||||||||||
// Hypothetically, we *could* read the module identity out of the system tables. | ||||||||||||||||||
|
@@ -796,6 +821,112 @@ impl DbContext for ReducerContext { | |||||||||||||||||
#[non_exhaustive] | ||||||||||||||||||
pub struct Local {} | ||||||||||||||||||
|
||||||||||||||||||
#[non_exhaustive] | ||||||||||||||||||
pub struct JwtClaims { | ||||||||||||||||||
payload: String, | ||||||||||||||||||
parsed: OnceCell<serde_json::Value>, | ||||||||||||||||||
audience: OnceCell<Vec<String>>, | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
/// Authentication information for the caller of a reducer. | ||||||||||||||||||
pub struct AuthCtx { | ||||||||||||||||||
is_internal: bool, | ||||||||||||||||||
// I can't directly use a LazyLock without making this struct generic. | ||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||
jwt: Box<dyn Deref<Target = Option<JwtClaims>>>, | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
impl AuthCtx { | ||||||||||||||||||
fn new<F>(is_internal: bool, jwt_fn: F) -> Self | ||||||||||||||||||
where | ||||||||||||||||||
F: FnOnce() -> Option<JwtClaims> + 'static, | ||||||||||||||||||
{ | ||||||||||||||||||
Comment on lines
+839
to
+842
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||
AuthCtx { | ||||||||||||||||||
is_internal, | ||||||||||||||||||
jwt: Box::new(LazyLock::new(jwt_fn)), | ||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||
} | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
/// Create an [`AuthCtx`] for an internal call, with no JWT. | ||||||||||||||||||
/// This represents a scheduled reducer. | ||||||||||||||||||
pub fn internal() -> AuthCtx { | ||||||||||||||||||
Self::new(true, || None) | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
/// Creates an [`AuthCtx`] using the json claims from a JWT. | ||||||||||||||||||
/// This can be used to write unit tests. | ||||||||||||||||||
pub fn from_jwt_payload(jwt_payload: String) -> AuthCtx { | ||||||||||||||||||
Self::new(false, move || Some(JwtClaims::new(jwt_payload))) | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
/// Creates an [`AuthCtx`] that reads the JWT for the given connection id. | ||||||||||||||||||
fn from_connection_id(connection_id: ConnectionId) -> AuthCtx { | ||||||||||||||||||
Self::new(false, move || rt::get_jwt(connection_id).map(JwtClaims::new)) | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
/// True if this reducer was spawned from inside the database. | ||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||
pub fn is_internal(&self) -> bool { | ||||||||||||||||||
self.is_internal | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
/// Check if there is a JWT without loading it. | ||||||||||||||||||
/// If is_internal is true, this will be false. | ||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||
pub fn has_jwt(&self) -> bool { | ||||||||||||||||||
self.jwt.is_some() | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
/// Load the jwt. | ||||||||||||||||||
pub fn jwt(&self) -> Option<&JwtClaims> { | ||||||||||||||||||
self.jwt.as_ref().deref().as_ref() | ||||||||||||||||||
} | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
impl JwtClaims { | ||||||||||||||||||
fn new(jwt: String) -> Self { | ||||||||||||||||||
Self { | ||||||||||||||||||
payload: jwt, | ||||||||||||||||||
parsed: OnceCell::new(), | ||||||||||||||||||
audience: OnceCell::new(), | ||||||||||||||||||
} | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
fn get_parsed(&self) -> &serde_json::Value { | ||||||||||||||||||
self.parsed | ||||||||||||||||||
.get_or_init(|| serde_json::from_str(&self.payload).expect("Failed to parse JWT payload")) | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
pub fn subject(&self) -> &str { | ||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. User facing docs? |
||||||||||||||||||
// TODO: Add more error messages here. | ||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This and similar needs to be done before we release |
||||||||||||||||||
self.get_parsed().get("sub").unwrap().as_str().unwrap() | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
pub fn issuer(&self) -> &str { | ||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. User facing docs? |
||||||||||||||||||
self.get_parsed().get("iss").unwrap().as_str().unwrap() | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
fn extract_audience(&self) -> Vec<String> { | ||||||||||||||||||
let aud = self.get_parsed().get("aud").unwrap(); | ||||||||||||||||||
match aud { | ||||||||||||||||||
serde_json::Value::String(s) => vec![s.clone()], | ||||||||||||||||||
serde_json::Value::Array(arr) => arr.iter().filter_map(|v| v.as_str().map(String::from)).collect(), | ||||||||||||||||||
_ => panic!("Unexpected type for 'aud' claim in JWT"), | ||||||||||||||||||
} | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
pub fn audience(&self) -> &[String] { | ||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. User facing docs? |
||||||||||||||||||
self.audience.get_or_init(|| self.extract_audience()) | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
// A convenience method, since this may not be in the token. | ||||||||||||||||||
pub fn identity(&self) -> Identity { | ||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. User facing docs? |
||||||||||||||||||
Identity::from_claims(self.issuer(), self.subject()) | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
// We can expose the whole payload for users that want to parse custom claims. | ||||||||||||||||||
pub fn raw_payload(&self) -> &str { | ||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. User facing docs? |
||||||||||||||||||
&self.payload | ||||||||||||||||||
} | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
// #[cfg(target_arch = "wasm32")] | ||||||||||||||||||
// #[global_allocator] | ||||||||||||||||||
// static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT; | ||||||||||||||||||
|
@@ -900,3 +1031,37 @@ macro_rules! __volatile_nonatomic_schedule_immediate_impl { | |||||||||||||||||
} | ||||||||||||||||||
}; | ||||||||||||||||||
} | ||||||||||||||||||
|
||||||||||||||||||
#[cfg(test)] | ||||||||||||||||||
mod tests { | ||||||||||||||||||
use super::*; | ||||||||||||||||||
|
||||||||||||||||||
#[test] | ||||||||||||||||||
fn parse_single_audience() { | ||||||||||||||||||
let example_payload = r#" | ||||||||||||||||||
{ | ||||||||||||||||||
"iss": "https://securetoken.google.com/my-project-id", | ||||||||||||||||||
"aud": "my-project-id", | ||||||||||||||||||
"auth_time": 1695560000, | ||||||||||||||||||
"user_id": "abc123XYZ", | ||||||||||||||||||
"sub": "abc123XYZ", | ||||||||||||||||||
"iat": 1695560100, | ||||||||||||||||||
"exp": 1695563700, | ||||||||||||||||||
"email": "[email protected]", | ||||||||||||||||||
"email_verified": true, | ||||||||||||||||||
"firebase": { | ||||||||||||||||||
"identities": { | ||||||||||||||||||
"email": ["[email protected]"] | ||||||||||||||||||
}, | ||||||||||||||||||
"sign_in_provider": "password" | ||||||||||||||||||
}, | ||||||||||||||||||
"name": "Jane Doe", | ||||||||||||||||||
"picture": "https://lh3.googleusercontent.com/a-/profile.jpg" | ||||||||||||||||||
} | ||||||||||||||||||
"#; | ||||||||||||||||||
let auth = AuthCtx::from_jwt_payload(example_payload.to_string()); | ||||||||||||||||||
let audience = auth.jwt().unwrap().audience(); | ||||||||||||||||||
assert_eq!(audience.len(), 1); | ||||||||||||||||||
assert_eq!(audience, &["my-project-id".to_string()]); | ||||||||||||||||||
} | ||||||||||||||||||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should follow the above documentation style in terms of ABI and traps and errors.