Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions clients/rust/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,19 @@ let client = Client::builder("http://localhost:8888/")
.build()?;
```

To mint a token with narrower permissions or a different expiry than the generator's
defaults, use [`TokenGenerator::sign_with`] (for a standalone token) or
[`Session::mint_token_with`] (scoped to a session). The requested permissions must be a
subset of those granted to the generator, otherwise an `Error::PermissionEscalation` is
returned:

```rust,ignore
use objectstore_client::auth::Permission;

// A read-only token that expires in 30 seconds, from a fully-privileged generator.
let read_only = session.mint_token_with(Some(&[Permission::ObjectRead]), Some(30))?;
```

## Configuration

In production, store the [`Client`] and [`Usecase`] in a `static` and reuse them.
Expand Down
59 changes: 54 additions & 5 deletions clients/rust/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,10 +158,33 @@ impl TokenGenerator {
/// Use this to produce a static token that can be handed to an external service
/// which then passes it to [`ClientBuilder::token`](crate::ClientBuilder::token).
///
/// The token is signed with the generator's default permissions and expiry. Use
/// [`sign_with`](Self::sign_with) to override them for a single token.
///
/// # Errors
///
/// Returns an error if the scope is invalid or the JWT cannot be signed.
pub fn sign(&self, scope: &crate::Scope) -> crate::Result<String> {
self.sign_with(scope, None, None)
}

/// Sign a token for the given [`Scope`](crate::Scope), optionally overriding the
/// generator's default permissions and/or expiry for this token only.
///
/// When `permissions` is `Some`, they must be a subset of the permissions granted to this
/// generator, otherwise an [`Error::PermissionEscalation`](crate::Error::PermissionEscalation)
/// is returned. When `expiry_seconds` is `Some`, it overrides the generator's default expiry.
///
/// # Errors
///
/// Returns an error if the scope is invalid, a requested permission is not granted to this
/// generator, or the JWT cannot be signed.
pub fn sign_with(
&self,
scope: &crate::Scope,
permissions: Option<&[Permission]>,
expiry_seconds: Option<u64>,
) -> crate::Result<String> {
let scope = match &scope.0 {
Ok(inner) => inner,
Err(crate::Error::InvalidScope(err)) => {
Expand All @@ -172,14 +195,20 @@ impl TokenGenerator {
// `InvalidScope`, unless we add a new variant and forget to update this code path.
_ => return Err(scope::InvalidScopeError::Unreachable.into()),
};
self.sign_for_scope(scope)
self.sign_for_scope(scope, permissions, expiry_seconds)
}

/// 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<String> {
/// Sign a new token for the passed-in scope, applying the given permission and expiry
/// overrides (falling back to the generator's defaults when `None`).
pub(crate) fn sign_for_scope(
&self,
scope: &ScopeInner,
permissions: Option<&[Permission]>,
expiry_seconds: Option<u64>,
) -> crate::Result<String> {
let claims = JwtClaims {
exp: get_current_timestamp() + self.expiry_seconds,
permissions: self.permissions.clone(),
exp: get_current_timestamp() + expiry_seconds.unwrap_or(self.expiry_seconds),
permissions: self.resolve_permissions(permissions)?,
res: JwtRes {
usecase: scope.usecase().name().into(),
scopes: scope
Expand All @@ -195,4 +224,24 @@ impl TokenGenerator {

Ok(encode(&header, &claims, &self.encoding_key)?)
}

/// Resolves the permissions to embed in a token, validating that any explicitly requested
/// permissions are a subset of those granted to this generator.
fn resolve_permissions(
&self,
requested: Option<&[Permission]>,
) -> crate::Result<HashSet<Permission>> {
let Some(requested) = requested else {
return Ok(self.permissions.clone());
};

let requested: HashSet<Permission> = requested.iter().copied().collect();
let mut escalated: Vec<Permission> =
requested.difference(&self.permissions).copied().collect();
if !escalated.is_empty() {
escalated.sort_by_key(Permission::to_string);
return Err(crate::Error::PermissionEscalation { escalated });
}
Ok(requested)
}
}
40 changes: 35 additions & 5 deletions clients/rust/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use reqwest::RequestBuilder;
use url::Url;

use crate::IntoTokenProvider;
use crate::auth::TokenProvider;
use crate::auth::{Permission, TokenProvider};

const USER_AGENT: &str = concat!("objectstore-client/", env!("CARGO_PKG_VERSION"));

Expand Down Expand Up @@ -456,13 +456,43 @@ impl Session {
url
}

/// Returns a signed token if a token or generator was provided or `None` otherwise
/// Returns a signed token if a token or generator was provided or `None` otherwise.
///
/// The token carries the configured provider's default permissions and expiry. Use
/// [`mint_token_with`](Self::mint_token_with) to override them for a single token.
pub fn mint_token(&self) -> crate::Result<Option<String>> {
self.mint_token_with(None, None)
}

/// Returns a signed token, optionally overriding the generator's default permissions
/// and/or expiry for this token only.
///
/// When `permissions` is `Some`, they must be a subset of the permissions granted to the
/// underlying [`TokenGenerator`](crate::TokenGenerator), otherwise an
/// [`Error::PermissionEscalation`](crate::Error::PermissionEscalation) is returned. When
/// `expiry_seconds` is `Some`, it overrides the generator's default expiry.
///
/// These overrides only apply to a [`TokenProvider::Generator`](crate::TokenProvider). A
/// static, pre-signed token cannot be re-scoped, so passing an override alongside one returns
/// [`Error::StaticTokenOverride`](crate::Error::StaticTokenOverride). Returns `None` when no
/// token or generator was provided.
pub fn mint_token_with(

@lcian lcian Jul 16, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should change both this method and sign_with to use the builder pattern.
We can keep mint_token and sign for backwards compatibility.
About errors, we can use the existing pattern we have on other builders in this very crate, that is, store a Result internally in the builder, and only return the result from build(). This way the caller needs to handle the error only at the build() call.

&self,
permissions: Option<&[Permission]>,
expiry_seconds: Option<u64>,
) -> crate::Result<Option<String>> {
match &self.client.token {
Some(TokenProvider::Generator(generator)) => {
Ok(Some(generator.sign_for_scope(&self.scope)?))
Some(TokenProvider::Generator(generator)) => Ok(Some(generator.sign_for_scope(
&self.scope,
permissions,
expiry_seconds,
)?)),
Some(TokenProvider::Static(token)) => {
if permissions.is_some() || expiry_seconds.is_some() {
return Err(crate::Error::StaticTokenOverride);
}
Ok(Some(token.clone()))
}
Some(TokenProvider::Static(token)) => Ok(Some(token.clone())),
None => Ok(None),
}
}
Expand Down
11 changes: 11 additions & 0 deletions clients/rust/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@ pub enum Error {
/// Error when creating auth tokens, such as invalid keys.
#[error(transparent)]
TokenError(#[from] jsonwebtoken::errors::Error),
/// Error when the permissions requested for a token exceed those granted to the
/// [`TokenGenerator`](crate::TokenGenerator).
#[error("requested permissions not granted to this token generator: {escalated:?}")]
PermissionEscalation {
/// The requested permissions that are not granted to the generator.
escalated: Vec<objectstore_types::auth::Permission>,
},
/// Error when per-token permission or expiry overrides are requested for a static
/// (pre-signed) token, which cannot be re-scoped.
#[error("static tokens cannot be re-scoped with custom permissions or expiry")]
StaticTokenOverride,
/// Error when URL manipulation fails.
#[error("{message}")]
InvalidUrl {
Expand Down
50 changes: 50 additions & 0 deletions clients/rust/tests/e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,56 @@ async fn fails_with_insufficient_auth_token_perms() {
}
}

#[tokio::test]
async fn mint_token_with_rejects_escalation() {
// A read-only generator cannot mint a token requesting write access.
let client = Client::builder("http://127.0.0.1:8888/")
.token(test_token_generator().permissions(&[Permission::ObjectRead]))
.build()
.unwrap();
let usecase = Usecase::new("usecase");
let session = client.session(usecase.for_organization(12345)).unwrap();

let result = session.mint_token_with(
Some(&[Permission::ObjectRead, Permission::ObjectWrite]),
None,
);
match result {
Err(Error::PermissionEscalation { escalated }) => {
assert_eq!(escalated, vec![Permission::ObjectWrite]);
}
other => panic!("Expected PermissionEscalation, got: {other:?}"),
}

// A subset of the granted permissions is accepted.
assert!(
session
.mint_token_with(Some(&[Permission::ObjectRead]), Some(30))
.unwrap()
.is_some()
);
}

#[tokio::test]
async fn mint_token_with_rejects_static_override() {
let token = sign_static_token("usecase", &[("org", "12345")]);
let client = Client::builder("http://127.0.0.1:8888/")
.token(token)
.build()
.unwrap();
let usecase = Usecase::new("usecase");
let session = client.session(usecase.for_organization(12345)).unwrap();

// Overrides cannot be applied to a static, pre-signed token.
assert!(matches!(
session.mint_token_with(Some(&[Permission::ObjectRead]), None),
Err(Error::StaticTokenOverride)
));

// Without overrides, the static token is returned as-is.
assert!(session.mint_token().unwrap().is_some());
}

#[tokio::test]
async fn stores_with_static_token() {
let server = test_server().await;
Expand Down
Loading