From eba14de0726d6a2d0c07028623a5f15b45527b12 Mon Sep 17 00:00:00 2001 From: Matt Hammerly Date: Mon, 15 Dec 2025 17:28:48 -0800 Subject: [PATCH] feat(auth): rust client support for bearer auth --- Cargo.lock | 1 + Cargo.toml | 1 + clients/rust/Cargo.toml | 1 + clients/rust/src/auth.rs | 106 ++++++++++++++++++++++++++++++++++ clients/rust/src/client.rs | 42 +++++++++++++- clients/rust/src/delete.rs | 2 +- clients/rust/src/error.rs | 3 + clients/rust/src/get.rs | 2 +- clients/rust/src/lib.rs | 2 + clients/rust/src/put.rs | 2 +- objectstore-server/Cargo.toml | 2 +- 11 files changed, 157 insertions(+), 7 deletions(-) create mode 100644 clients/rust/src/auth.rs diff --git a/Cargo.lock b/Cargo.lock index a291fce1..a316c304 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2180,6 +2180,7 @@ dependencies = [ "bytes", "futures-util", "infer", + "jsonwebtoken", "objectstore-test", "objectstore-types", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index 864399ad..cd6bd889 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ futures-util = "0.3.31" http = "1.3.1" humantime = "2.2.0" humantime-serde = "1.1.1" +jsonwebtoken = { version = "10.2.0", features = ["rust_crypto"] } merni = "0.1.3" mimalloc = { version = "0.1.48", features = ["v3", "override"] } rand = "0.9.1" diff --git a/clients/rust/Cargo.toml b/clients/rust/Cargo.toml index 3d20b77f..445cdabc 100644 --- a/clients/rust/Cargo.toml +++ b/clients/rust/Cargo.toml @@ -15,6 +15,7 @@ async-compression = { version = "0.4.27", features = ["tokio", "zstd"] } bytes = { workspace = true } futures-util = { workspace = true } infer = { version = "0.19.0", default-features = false } +jsonwebtoken = { workspace = true } objectstore-types = { workspace = true } reqwest = { workspace = true, features = ["json", "stream"] } sentry-core = { version = ">=0.41", features = ["client"] } diff --git a/clients/rust/src/auth.rs b/clients/rust/src/auth.rs new file mode 100644 index 00000000..ed1aee57 --- /dev/null +++ b/clients/rust/src/auth.rs @@ -0,0 +1,106 @@ +use std::collections::{BTreeMap, HashSet}; + +use jsonwebtoken::{Algorithm, EncodingKey, Header, encode, get_current_timestamp}; +use objectstore_types::Permission; +use serde::{Deserialize, Serialize}; + +use crate::ScopeInner; + +const DEFAULT_EXPIRY_SECONDS: u64 = 60; +const DEFAULT_PERMISSIONS: [Permission; 3] = [ + Permission::ObjectRead, + Permission::ObjectWrite, + Permission::ObjectDelete, +]; + +/// Key configuration that will be used to sign tokens in Objectstore requests. +pub struct SecretKey { + /// A key ID that Objectstore must use to load the corresponding public key. + pub kid: String, + + /// An EdDSA private key. + pub secret_key: String, +} + +impl std::fmt::Debug for SecretKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SecretKey") + .field("kid", &self.kid) + .field("secret_key", &"[redacted]") + .finish() + } +} + +/// A utility to generate auth tokens to be used in Objectstore requests. +/// +/// Tokens are signed with an EdDSA private key and have certain permissions and expiry timeouts +/// applied. +#[derive(Debug)] +pub struct TokenGenerator { + kid: String, + encoding_key: EncodingKey, + expiry_seconds: u64, + permissions: HashSet, +} + +#[derive(Serialize, Deserialize)] +struct JwtRes { + #[serde(rename = "os:usecase")] + usecase: String, + + #[serde(flatten)] + scopes: BTreeMap, +} + +#[derive(Serialize, Deserialize)] +struct JwtClaims { + exp: u64, + permissions: HashSet, + res: JwtRes, +} + +impl TokenGenerator { + /// Create a new [`TokenGenerator`] for a given key configuration. + pub fn new(secret_key: SecretKey) -> crate::Result { + let encoding_key = EncodingKey::from_ed_pem(secret_key.secret_key.as_bytes())?; + Ok(TokenGenerator { + kid: secret_key.kid, + encoding_key, + expiry_seconds: DEFAULT_EXPIRY_SECONDS, + permissions: HashSet::from(DEFAULT_PERMISSIONS), + }) + } + + /// Set the expiry duration for tokens signed by this generator. + pub fn expiry_seconds(mut self, expiry_seconds: u64) -> Self { + self.expiry_seconds = expiry_seconds; + self + } + + /// Set the permissions that will be granted to tokens signed by this generator. + pub fn permissions(mut self, permissions: &[Permission]) -> Self { + self.permissions = HashSet::from_iter(permissions.iter().cloned()); + self + } + + /// Sign a new token for the passed-in scope using the configured expiry and permissions. + pub(crate) fn sign_for_scope(&self, scope: &ScopeInner) -> crate::Result { + let claims = JwtClaims { + exp: get_current_timestamp() + self.expiry_seconds, + permissions: self.permissions.clone(), + res: JwtRes { + usecase: scope.usecase().name().into(), + scopes: scope + .scopes() + .iter() + .map(|scope| (scope.name().to_string(), scope.value().to_string())) + .collect(), + }, + }; + + let mut header = Header::new(Algorithm::EdDSA); + header.kid = Some(self.kid.clone()); + + Ok(encode(&header, &claims, &self.encoding_key)?) + } +} diff --git a/clients/rust/src/client.rs b/clients/rust/src/client.rs index a82278b0..0c7c15a1 100644 --- a/clients/rust/src/client.rs +++ b/clients/rust/src/client.rs @@ -7,6 +7,8 @@ use futures_util::stream::BoxStream; use objectstore_types::{Compression, ExpirationPolicy, scope}; use url::Url; +use crate::auth::TokenGenerator; + const USER_AGENT: &str = concat!("objectstore-client/", env!("CARGO_PKG_VERSION")); #[derive(Debug)] @@ -14,6 +16,7 @@ struct ClientBuilderInner { service_url: Url, propagate_traces: bool, reqwest_builder: reqwest::ClientBuilder, + token_generator: Option, } impl ClientBuilderInner { @@ -66,6 +69,7 @@ impl ClientBuilder { service_url, propagate_traces: false, reqwest_builder, + token_generator: None, })) } @@ -105,6 +109,14 @@ impl ClientBuilder { Self(Ok(inner)) } + /// Sets a [`TokenGenerator`] that will be used to sign authorization tokens before + /// sending requests to Objectstore. + pub fn token_generator(self, token_generator: TokenGenerator) -> Self { + let Ok(mut inner) = self.0 else { return self }; + inner.token_generator = Some(token_generator); + Self(Ok(inner)) + } + /// Returns a [`Client`] that uses this [`ClientBuilder`] configuration. /// /// # Errors @@ -121,6 +133,7 @@ impl ClientBuilder { reqwest: inner.reqwest_builder.build()?, service_url: inner.service_url, propagate_traces: inner.propagate_traces, + token_generator: inner.token_generator, }), }) } @@ -224,6 +237,11 @@ impl ScopeInner { pub(crate) fn usecase(&self) -> &Usecase { &self.usecase } + + #[inline] + pub(crate) fn scopes(&self) -> &scope::Scopes { + &self.scopes + } } /// A [`Scope`] is a sequence of key-value pairs that defines a (possibly nested) namespace within a @@ -281,6 +299,7 @@ pub(crate) struct ClientInner { reqwest: reqwest::Client, service_url: Url, propagate_traces: bool, + token_generator: Option, } /// A client for Objectstore. Use [`Client::builder`] to configure and construct a Client. @@ -288,16 +307,28 @@ pub(crate) struct ClientInner { /// To perform CRUD operations, one has to create a Client, and then scope it to a [`Usecase`] /// and Scope in order to create a [`Session`]. /// +/// If your Objectstore instance enforces authorization checks, you must provide a +/// [`TokenGenerator`] on creation. +/// /// # Example /// /// ```no_run /// use std::time::Duration; -/// use objectstore_client::{Client, Usecase}; +/// use objectstore_client::{Client, SecretKey, TokenGenerator, Usecase}; +/// use objectstore_types::Permission; /// /// # async fn example() -> objectstore_client::Result<()> { +/// let token_generator = TokenGenerator::new(SecretKey { +/// secret_key: "".into(), +/// kid: "my-service".into(), +/// })? +/// .expiry_seconds(30) +/// .permissions(&[Permission::ObjectRead]); +/// /// let client = Client::builder("http://localhost:8888/") /// .timeout(Duration::from_secs(1)) /// .propagate_traces(true) +/// .token_generator(token_generator) /// .build()?; /// /// let session = Usecase::new("my_app") @@ -384,11 +415,16 @@ impl Session { &self, method: reqwest::Method, object_key: &str, - ) -> reqwest::RequestBuilder { + ) -> crate::Result { let url = self.object_url(object_key); let mut builder = self.client.reqwest.request(method, url); + if let Some(token_generator) = &self.client.token_generator { + let token = token_generator.sign_for_scope(&self.scope)?; + builder = builder.bearer_auth(token); + } + if self.client.propagate_traces { let trace_headers = sentry_core::configure_scope(|scope| Some(scope.iter_trace_propagation_headers())); @@ -397,7 +433,7 @@ impl Session { } } - builder + Ok(builder) } } diff --git a/clients/rust/src/delete.rs b/clients/rust/src/delete.rs index 27236926..edd71552 100644 --- a/clients/rust/src/delete.rs +++ b/clients/rust/src/delete.rs @@ -24,7 +24,7 @@ impl DeleteBuilder { /// Sends the delete request. pub async fn send(self) -> crate::Result { self.session - .request(reqwest::Method::DELETE, &self.key) + .request(reqwest::Method::DELETE, &self.key)? .send() .await?; Ok(()) diff --git a/clients/rust/src/error.rs b/clients/rust/src/error.rs index 5b5f03b0..151afe45 100644 --- a/clients/rust/src/error.rs +++ b/clients/rust/src/error.rs @@ -16,6 +16,9 @@ pub enum Error { /// Error when scope validation fails. #[error("invalid scope: {0}")] InvalidScope(#[from] objectstore_types::scope::InvalidScopeError), + /// Error when creating auth tokens, such as invalid keys. + #[error(transparent)] + TokenError(#[from] jsonwebtoken::errors::Error), /// Error when URL manipulation fails. #[error("{message}")] InvalidUrl { diff --git a/clients/rust/src/get.rs b/clients/rust/src/get.rs index ea1bd210..e56956c6 100644 --- a/clients/rust/src/get.rs +++ b/clients/rust/src/get.rs @@ -77,7 +77,7 @@ impl GetBuilder { pub async fn send(self) -> crate::Result> { let response = self .session - .request(reqwest::Method::GET, &self.key) + .request(reqwest::Method::GET, &self.key)? .send() .await?; if response.status() == StatusCode::NOT_FOUND { diff --git a/clients/rust/src/lib.rs b/clients/rust/src/lib.rs index be109622..d5a9ed1c 100644 --- a/clients/rust/src/lib.rs +++ b/clients/rust/src/lib.rs @@ -2,6 +2,7 @@ #![warn(missing_docs)] #![warn(missing_debug_implementations)] +mod auth; mod client; mod delete; mod error; @@ -11,6 +12,7 @@ pub mod utils; pub use objectstore_types::{Compression, ExpirationPolicy}; +pub use auth::*; pub use client::*; pub use delete::*; pub use error::*; diff --git a/clients/rust/src/put.rs b/clients/rust/src/put.rs index 6f2d5571..fef4edcb 100644 --- a/clients/rust/src/put.rs +++ b/clients/rust/src/put.rs @@ -145,7 +145,7 @@ impl PutBuilder { let mut builder = self .session - .request(method, self.key.as_deref().unwrap_or_default()); + .request(method, self.key.as_deref().unwrap_or_default())?; let body = match (self.metadata.compression, self.body) { (Some(Compression::Zstd), PutBody::Buffer(bytes)) => { diff --git a/objectstore-server/Cargo.toml b/objectstore-server/Cargo.toml index 36dbd4af..2e0386f9 100644 --- a/objectstore-server/Cargo.toml +++ b/objectstore-server/Cargo.toml @@ -21,7 +21,7 @@ figment = { version = "0.10.19", features = ["env", "test", "yaml"] } futures-util = { workspace = true } humantime = { workspace = true } humantime-serde = { workspace = true } -jsonwebtoken = { version = "10.2.0", features = ["rust_crypto"] } +jsonwebtoken = { workspace = true } merni = { workspace = true } mimalloc = { workspace = true } num_cpus = "1.17.0"