Skip to content

Commit eba14de

Browse files
committed
feat(auth): rust client support for bearer auth
1 parent f044fcb commit eba14de

File tree

11 files changed

+157
-7
lines changed

11 files changed

+157
-7
lines changed

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.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ futures-util = "0.3.31"
3636
http = "1.3.1"
3737
humantime = "2.2.0"
3838
humantime-serde = "1.1.1"
39+
jsonwebtoken = { version = "10.2.0", features = ["rust_crypto"] }
3940
merni = "0.1.3"
4041
mimalloc = { version = "0.1.48", features = ["v3", "override"] }
4142
rand = "0.9.1"

clients/rust/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ async-compression = { version = "0.4.27", features = ["tokio", "zstd"] }
1515
bytes = { workspace = true }
1616
futures-util = { workspace = true }
1717
infer = { version = "0.19.0", default-features = false }
18+
jsonwebtoken = { workspace = true }
1819
objectstore-types = { workspace = true }
1920
reqwest = { workspace = true, features = ["json", "stream"] }
2021
sentry-core = { version = ">=0.41", features = ["client"] }

clients/rust/src/auth.rs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
use std::collections::{BTreeMap, HashSet};
2+
3+
use jsonwebtoken::{Algorithm, EncodingKey, Header, encode, get_current_timestamp};
4+
use objectstore_types::Permission;
5+
use serde::{Deserialize, Serialize};
6+
7+
use crate::ScopeInner;
8+
9+
const DEFAULT_EXPIRY_SECONDS: u64 = 60;
10+
const DEFAULT_PERMISSIONS: [Permission; 3] = [
11+
Permission::ObjectRead,
12+
Permission::ObjectWrite,
13+
Permission::ObjectDelete,
14+
];
15+
16+
/// Key configuration that will be used to sign tokens in Objectstore requests.
17+
pub struct SecretKey {
18+
/// A key ID that Objectstore must use to load the corresponding public key.
19+
pub kid: String,
20+
21+
/// An EdDSA private key.
22+
pub secret_key: String,
23+
}
24+
25+
impl std::fmt::Debug for SecretKey {
26+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27+
f.debug_struct("SecretKey")
28+
.field("kid", &self.kid)
29+
.field("secret_key", &"[redacted]")
30+
.finish()
31+
}
32+
}
33+
34+
/// A utility to generate auth tokens to be used in Objectstore requests.
35+
///
36+
/// Tokens are signed with an EdDSA private key and have certain permissions and expiry timeouts
37+
/// applied.
38+
#[derive(Debug)]
39+
pub struct TokenGenerator {
40+
kid: String,
41+
encoding_key: EncodingKey,
42+
expiry_seconds: u64,
43+
permissions: HashSet<Permission>,
44+
}
45+
46+
#[derive(Serialize, Deserialize)]
47+
struct JwtRes {
48+
#[serde(rename = "os:usecase")]
49+
usecase: String,
50+
51+
#[serde(flatten)]
52+
scopes: BTreeMap<String, String>,
53+
}
54+
55+
#[derive(Serialize, Deserialize)]
56+
struct JwtClaims {
57+
exp: u64,
58+
permissions: HashSet<Permission>,
59+
res: JwtRes,
60+
}
61+
62+
impl TokenGenerator {
63+
/// Create a new [`TokenGenerator`] for a given key configuration.
64+
pub fn new(secret_key: SecretKey) -> crate::Result<TokenGenerator> {
65+
let encoding_key = EncodingKey::from_ed_pem(secret_key.secret_key.as_bytes())?;
66+
Ok(TokenGenerator {
67+
kid: secret_key.kid,
68+
encoding_key,
69+
expiry_seconds: DEFAULT_EXPIRY_SECONDS,
70+
permissions: HashSet::from(DEFAULT_PERMISSIONS),
71+
})
72+
}
73+
74+
/// Set the expiry duration for tokens signed by this generator.
75+
pub fn expiry_seconds(mut self, expiry_seconds: u64) -> Self {
76+
self.expiry_seconds = expiry_seconds;
77+
self
78+
}
79+
80+
/// Set the permissions that will be granted to tokens signed by this generator.
81+
pub fn permissions(mut self, permissions: &[Permission]) -> Self {
82+
self.permissions = HashSet::from_iter(permissions.iter().cloned());
83+
self
84+
}
85+
86+
/// Sign a new token for the passed-in scope using the configured expiry and permissions.
87+
pub(crate) fn sign_for_scope(&self, scope: &ScopeInner) -> crate::Result<String> {
88+
let claims = JwtClaims {
89+
exp: get_current_timestamp() + self.expiry_seconds,
90+
permissions: self.permissions.clone(),
91+
res: JwtRes {
92+
usecase: scope.usecase().name().into(),
93+
scopes: scope
94+
.scopes()
95+
.iter()
96+
.map(|scope| (scope.name().to_string(), scope.value().to_string()))
97+
.collect(),
98+
},
99+
};
100+
101+
let mut header = Header::new(Algorithm::EdDSA);
102+
header.kid = Some(self.kid.clone());
103+
104+
Ok(encode(&header, &claims, &self.encoding_key)?)
105+
}
106+
}

clients/rust/src/client.rs

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,16 @@ use futures_util::stream::BoxStream;
77
use objectstore_types::{Compression, ExpirationPolicy, scope};
88
use url::Url;
99

10+
use crate::auth::TokenGenerator;
11+
1012
const USER_AGENT: &str = concat!("objectstore-client/", env!("CARGO_PKG_VERSION"));
1113

1214
#[derive(Debug)]
1315
struct ClientBuilderInner {
1416
service_url: Url,
1517
propagate_traces: bool,
1618
reqwest_builder: reqwest::ClientBuilder,
19+
token_generator: Option<TokenGenerator>,
1720
}
1821

1922
impl ClientBuilderInner {
@@ -66,6 +69,7 @@ impl ClientBuilder {
6669
service_url,
6770
propagate_traces: false,
6871
reqwest_builder,
72+
token_generator: None,
6973
}))
7074
}
7175

@@ -105,6 +109,14 @@ impl ClientBuilder {
105109
Self(Ok(inner))
106110
}
107111

112+
/// Sets a [`TokenGenerator`] that will be used to sign authorization tokens before
113+
/// sending requests to Objectstore.
114+
pub fn token_generator(self, token_generator: TokenGenerator) -> Self {
115+
let Ok(mut inner) = self.0 else { return self };
116+
inner.token_generator = Some(token_generator);
117+
Self(Ok(inner))
118+
}
119+
108120
/// Returns a [`Client`] that uses this [`ClientBuilder`] configuration.
109121
///
110122
/// # Errors
@@ -121,6 +133,7 @@ impl ClientBuilder {
121133
reqwest: inner.reqwest_builder.build()?,
122134
service_url: inner.service_url,
123135
propagate_traces: inner.propagate_traces,
136+
token_generator: inner.token_generator,
124137
}),
125138
})
126139
}
@@ -224,6 +237,11 @@ impl ScopeInner {
224237
pub(crate) fn usecase(&self) -> &Usecase {
225238
&self.usecase
226239
}
240+
241+
#[inline]
242+
pub(crate) fn scopes(&self) -> &scope::Scopes {
243+
&self.scopes
244+
}
227245
}
228246

229247
/// A [`Scope`] is a sequence of key-value pairs that defines a (possibly nested) namespace within a
@@ -281,23 +299,36 @@ pub(crate) struct ClientInner {
281299
reqwest: reqwest::Client,
282300
service_url: Url,
283301
propagate_traces: bool,
302+
token_generator: Option<TokenGenerator>,
284303
}
285304

286305
/// A client for Objectstore. Use [`Client::builder`] to configure and construct a Client.
287306
///
288307
/// To perform CRUD operations, one has to create a Client, and then scope it to a [`Usecase`]
289308
/// and Scope in order to create a [`Session`].
290309
///
310+
/// If your Objectstore instance enforces authorization checks, you must provide a
311+
/// [`TokenGenerator`] on creation.
312+
///
291313
/// # Example
292314
///
293315
/// ```no_run
294316
/// use std::time::Duration;
295-
/// use objectstore_client::{Client, Usecase};
317+
/// use objectstore_client::{Client, SecretKey, TokenGenerator, Usecase};
318+
/// use objectstore_types::Permission;
296319
///
297320
/// # async fn example() -> objectstore_client::Result<()> {
321+
/// let token_generator = TokenGenerator::new(SecretKey {
322+
/// secret_key: "<safely inject secret key>".into(),
323+
/// kid: "my-service".into(),
324+
/// })?
325+
/// .expiry_seconds(30)
326+
/// .permissions(&[Permission::ObjectRead]);
327+
///
298328
/// let client = Client::builder("http://localhost:8888/")
299329
/// .timeout(Duration::from_secs(1))
300330
/// .propagate_traces(true)
331+
/// .token_generator(token_generator)
301332
/// .build()?;
302333
///
303334
/// let session = Usecase::new("my_app")
@@ -384,11 +415,16 @@ impl Session {
384415
&self,
385416
method: reqwest::Method,
386417
object_key: &str,
387-
) -> reqwest::RequestBuilder {
418+
) -> crate::Result<reqwest::RequestBuilder> {
388419
let url = self.object_url(object_key);
389420

390421
let mut builder = self.client.reqwest.request(method, url);
391422

423+
if let Some(token_generator) = &self.client.token_generator {
424+
let token = token_generator.sign_for_scope(&self.scope)?;
425+
builder = builder.bearer_auth(token);
426+
}
427+
392428
if self.client.propagate_traces {
393429
let trace_headers =
394430
sentry_core::configure_scope(|scope| Some(scope.iter_trace_propagation_headers()));
@@ -397,7 +433,7 @@ impl Session {
397433
}
398434
}
399435

400-
builder
436+
Ok(builder)
401437
}
402438
}
403439

clients/rust/src/delete.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ impl DeleteBuilder {
2424
/// Sends the delete request.
2525
pub async fn send(self) -> crate::Result<DeleteResponse> {
2626
self.session
27-
.request(reqwest::Method::DELETE, &self.key)
27+
.request(reqwest::Method::DELETE, &self.key)?
2828
.send()
2929
.await?;
3030
Ok(())

clients/rust/src/error.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ pub enum Error {
1616
/// Error when scope validation fails.
1717
#[error("invalid scope: {0}")]
1818
InvalidScope(#[from] objectstore_types::scope::InvalidScopeError),
19+
/// Error when creating auth tokens, such as invalid keys.
20+
#[error(transparent)]
21+
TokenError(#[from] jsonwebtoken::errors::Error),
1922
/// Error when URL manipulation fails.
2023
#[error("{message}")]
2124
InvalidUrl {

clients/rust/src/get.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ impl GetBuilder {
7777
pub async fn send(self) -> crate::Result<Option<GetResponse>> {
7878
let response = self
7979
.session
80-
.request(reqwest::Method::GET, &self.key)
80+
.request(reqwest::Method::GET, &self.key)?
8181
.send()
8282
.await?;
8383
if response.status() == StatusCode::NOT_FOUND {

clients/rust/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
#![warn(missing_docs)]
33
#![warn(missing_debug_implementations)]
44

5+
mod auth;
56
mod client;
67
mod delete;
78
mod error;
@@ -11,6 +12,7 @@ pub mod utils;
1112

1213
pub use objectstore_types::{Compression, ExpirationPolicy};
1314

15+
pub use auth::*;
1416
pub use client::*;
1517
pub use delete::*;
1618
pub use error::*;

clients/rust/src/put.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ impl PutBuilder {
145145

146146
let mut builder = self
147147
.session
148-
.request(method, self.key.as_deref().unwrap_or_default());
148+
.request(method, self.key.as_deref().unwrap_or_default())?;
149149

150150
let body = match (self.metadata.compression, self.body) {
151151
(Some(Compression::Zstd), PutBody::Buffer(bytes)) => {

0 commit comments

Comments
 (0)