Skip to content

Commit 0c50276

Browse files
committed
feat(auth): rust client support for bearer auth
1 parent e59ed38 commit 0c50276

File tree

11 files changed

+145
-6
lines changed

11 files changed

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

clients/rust/src/client.rs

Lines changed: 26 additions & 2 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,6 +299,7 @@ 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.
@@ -384,11 +403,16 @@ impl Session {
384403
&self,
385404
method: reqwest::Method,
386405
object_key: &str,
387-
) -> reqwest::RequestBuilder {
406+
) -> crate::Result<reqwest::RequestBuilder> {
388407
let url = self.object_url(object_key);
389408

390409
let mut builder = self.client.reqwest.request(method, url);
391410

411+
if let Some(token_generator) = &self.client.token_generator {
412+
let token = token_generator.sign_for_scope(self.scope.clone())?;
413+
builder = builder.bearer_auth(token);
414+
}
415+
392416
if self.client.propagate_traces {
393417
let trace_headers =
394418
sentry_core::configure_scope(|scope| Some(scope.iter_trace_propagation_headers()));
@@ -397,7 +421,7 @@ impl Session {
397421
}
398422
}
399423

400-
builder
424+
Ok(builder)
401425
}
402426
}
403427

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)