From 70127d39c7b54f4e7a3fc12e25552e8d82cfed33 Mon Sep 17 00:00:00 2001 From: Matt Hammerly Date: Wed, 8 Jul 2026 16:23:03 -0700 Subject: [PATCH 1/4] feat(rust-client): support overriding token permission/expiry at mint time --- clients/rust/README.md | 13 +++++++++ clients/rust/src/auth.rs | 59 ++++++++++++++++++++++++++++++++++---- clients/rust/src/client.rs | 40 ++++++++++++++++++++++---- clients/rust/src/error.rs | 11 +++++++ clients/rust/tests/e2e.rs | 50 ++++++++++++++++++++++++++++++++ 5 files changed, 163 insertions(+), 10 deletions(-) diff --git a/clients/rust/README.md b/clients/rust/README.md index 8de5ae21..01df1bda 100644 --- a/clients/rust/README.md +++ b/clients/rust/README.md @@ -320,6 +320,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. diff --git a/clients/rust/src/auth.rs b/clients/rust/src/auth.rs index ae043ebf..55a3d026 100644 --- a/clients/rust/src/auth.rs +++ b/clients/rust/src/auth.rs @@ -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 { + 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, + ) -> crate::Result { let scope = match &scope.0 { Ok(inner) => inner, Err(crate::Error::InvalidScope(err)) => { @@ -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 { + /// 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, + ) -> crate::Result { 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 @@ -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> { + let Some(requested) = requested else { + return Ok(self.permissions.clone()); + }; + + let requested: HashSet = requested.iter().copied().collect(); + let mut escalated: Vec = + 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) + } } diff --git a/clients/rust/src/client.rs b/clients/rust/src/client.rs index 3f34785d..35d2f85b 100644 --- a/clients/rust/src/client.rs +++ b/clients/rust/src/client.rs @@ -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")); @@ -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> { + 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( + &self, + permissions: Option<&[Permission]>, + expiry_seconds: Option, + ) -> crate::Result> { 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), } } diff --git a/clients/rust/src/error.rs b/clients/rust/src/error.rs index 341ea47d..e36b8144 100644 --- a/clients/rust/src/error.rs +++ b/clients/rust/src/error.rs @@ -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, + }, + /// 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 { diff --git a/clients/rust/tests/e2e.rs b/clients/rust/tests/e2e.rs index ea39734f..c9724c42 100644 --- a/clients/rust/tests/e2e.rs +++ b/clients/rust/tests/e2e.rs @@ -366,6 +366,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; From 4a3769550863c1d32f75a86e4a898d149c03c8a5 Mon Sep 17 00:00:00 2001 From: Jan Michael Auer Date: Tue, 4 Aug 2026 18:04:27 +0200 Subject: [PATCH 2/4] ref(rust-client): Expose token overrides through a TokenRequest builder Replaces the positional-override methods with a builder returned from both TokenGenerator::create_token and Session::create_token, configured via permissions() and expiry_seconds() and finalized with sign(). Session::create_token yields the builder only when a token generator is configured, so callers can decide whether to skip or fail when it is absent. Session::get_token replaces mint_token for retrieving the session's token as-is. The old sign_with and mint_token_with are removed; sign and mint_token are deprecated. --- clients/rust/README.md | 23 ++---- clients/rust/src/auth.rs | 144 +++++++++++++++++++++---------------- clients/rust/src/client.rs | 63 ++++++++-------- clients/rust/tests/e2e.rs | 43 +++++++---- 4 files changed, 150 insertions(+), 123 deletions(-) diff --git a/clients/rust/README.md b/clients/rust/README.md index 01df1bda..e5147cf6 100644 --- a/clients/rust/README.md +++ b/clients/rust/README.md @@ -307,31 +307,22 @@ let client = Client::builder("http://localhost:8888/") .build()?; // Option 2: External service with a pre-signed JWT -// Use TokenGenerator::sign() to obtain a static token from an internal -// service, then pass it to the external consumer: +// Use TokenGenerator::create_token() to obtain a static token from an authority let scope = Usecase::new("my_app").for_project(42, 1337); -let token = TokenGenerator::new(SecretKey { +let generator = TokenGenerator::new(SecretKey { secret_key: "".into(), kid: "my-service".into(), -})?.sign(&scope)?; +})?; +let token = generator.create_token(&scope).sign()?; +// Then pass the token directly to a client builder. let client = Client::builder("http://localhost:8888/") .token(token) .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))?; -``` +To create a token with narrower permissions or a different expiry, use the +methods on the token request returned from `create_token`. ## Configuration diff --git a/clients/rust/src/auth.rs b/clients/rust/src/auth.rs index 55a3d026..79d9240e 100644 --- a/clients/rust/src/auth.rs +++ b/clients/rust/src/auth.rs @@ -4,7 +4,7 @@ use jsonwebtoken::{Algorithm, EncodingKey, Header, encode, get_current_timestamp use objectstore_types::scope; use serde::{Deserialize, Serialize}; -use crate::ScopeInner; +use crate::{Scope, ScopeInner}; pub use objectstore_types::auth::Permission; @@ -153,62 +153,102 @@ impl TokenGenerator { self } - /// Sign a token for the given [`Scope`](crate::Scope), returning the JWT string. + /// Deprecated. Use [`create_token`](Self::create_token) instead. + #[deprecated(note = "Use `create_token(scope).sign()` instead")] + pub fn sign(&self, scope: &Scope) -> crate::Result { + self.create_token(scope).sign() + } + + /// Create a token for the given [`Scope`](crate::Scope). /// /// 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 { - self.sign_with(scope, None, None) + /// By default, the token is signed with the generator's default permissions and expiry. + /// Use the builder methods on the returned [`TokenRequest`] to customize them. + pub fn create_token<'a>(&'a self, scope: &'a Scope) -> TokenRequest<'a> { + TokenRequest { + generator: self, + scope: scope.0.as_ref(), + permissions: None, + expiry_seconds: 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. + pub(crate) fn request_inner<'a>(&'a self, scope: &'a ScopeInner) -> TokenRequest<'a> { + TokenRequest { + generator: self, + scope: Ok(scope), + permissions: None, + expiry_seconds: None, + } + } + + /// 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> { + let Some(requested) = requested else { + return Ok(self.permissions.clone()); + }; + + let requested: HashSet = requested.iter().copied().collect(); + let mut escalated: Vec = + 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) + } +} + +/// A request to mint a new token returned from [`TokenGenerator::create_token`]. +#[derive(Debug)] +pub struct TokenRequest<'a> { + generator: &'a TokenGenerator, + scope: Result<&'a ScopeInner, &'a crate::Error>, + permissions: Option>, + expiry_seconds: Option, +} + +impl TokenRequest<'_> { + /// Override the permissions for this token, which must be a subset of the generator's + /// permissions. + pub fn permissions(mut self, permissions: &[Permission]) -> Self { + self.permissions = Some(permissions.to_vec()); + self + } + + /// Set the expiry duration for this token, overriding the generator's default. + pub fn expiry_seconds(mut self, expiry_seconds: u64) -> Self { + self.expiry_seconds = Some(expiry_seconds); + self + } + + /// Finalizes and signs the token, returning the JWT string. /// /// # 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, - ) -> crate::Result { - let scope = match &scope.0 { + /// Returns an error if the scope is invalid or the JWT cannot be signed. + pub fn sign(&self) -> crate::Result { + let scope = match self.scope { Ok(inner) => inner, - Err(crate::Error::InvalidScope(err)) => { - return Err(err.clone().into()); - } + Err(crate::Error::InvalidScope(err)) => return Err(err.clone().into()), // Return an ad-hoc `Unreachable` variant to avoid panicking. // It should be impossible to run into a different error variant other than // `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, permissions, expiry_seconds) - } - /// 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, - ) -> crate::Result { let claims = JwtClaims { - exp: get_current_timestamp() + expiry_seconds.unwrap_or(self.expiry_seconds), - permissions: self.resolve_permissions(permissions)?, + exp: get_current_timestamp() + + self.expiry_seconds.unwrap_or(self.generator.expiry_seconds), + permissions: self + .generator + .resolve_permissions(self.permissions.as_deref())?, res: JwtRes { usecase: scope.usecase().name().into(), scopes: scope @@ -220,28 +260,8 @@ impl TokenGenerator { }; let mut header = Header::new(Algorithm::EdDSA); - header.kid = Some(self.kid.clone()); - - 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> { - let Some(requested) = requested else { - return Ok(self.permissions.clone()); - }; + header.kid = Some(self.generator.kid.clone()); - let requested: HashSet = requested.iter().copied().collect(); - let mut escalated: Vec = - 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) + Ok(encode(&header, &claims, &self.generator.encoding_key)?) } } diff --git a/clients/rust/src/client.rs b/clients/rust/src/client.rs index 35d2f85b..3bc6adca 100644 --- a/clients/rust/src/client.rs +++ b/clients/rust/src/client.rs @@ -10,7 +10,7 @@ use reqwest::RequestBuilder; use url::Url; use crate::IntoTokenProvider; -use crate::auth::{Permission, TokenProvider}; +use crate::auth::{TokenProvider, TokenRequest}; const USER_AGENT: &str = concat!("objectstore-client/", env!("CARGO_PKG_VERSION")); @@ -350,7 +350,7 @@ pub(crate) struct ClientInner { /// ``` /// /// External service with a pre-signed JWT (obtained via -/// [`TokenGenerator::sign`](crate::TokenGenerator::sign)): +/// [`TokenGenerator::create_token`](crate::TokenGenerator::create_token)): /// /// ```no_run /// use objectstore_client::{Client, SecretKey, TokenGenerator, Usecase}; @@ -360,7 +360,7 @@ pub(crate) struct ClientInner { /// let token = TokenGenerator::new(SecretKey { /// secret_key: "".into(), /// kid: "my-service".into(), -/// })?.sign(&scope)?; +/// })?.create_token(&scope).sign()?; /// /// let client = Client::builder("http://localhost:8888/") /// .token(token) @@ -459,40 +459,39 @@ impl Session { /// 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. + /// [`create_token`](Self::create_token) to customize these. + pub fn get_token(&self) -> crate::Result> { + match self.client.token { + Some(TokenProvider::Generator(ref generator)) => { + generator.request_inner(&self.scope).sign().map(Some) + } + Some(TokenProvider::Static(ref token)) => Ok(Some(token.clone())), + None => Ok(None), + } + } + + /// Deprecated. Use [`get_token`](Self::get_token) instead. + #[deprecated(note = "Use `get_token` instead.")] pub fn mint_token(&self) -> crate::Result> { - self.mint_token_with(None, None) + self.get_token() } - /// Returns a signed token, optionally overriding the generator's default permissions - /// and/or expiry for this token only. + /// Creates a new token with configurable permissions and expiry. /// - /// 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. + /// Use this to produce a static token that can be handed to an external service + /// with lower permissions than the current session. /// - /// 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( - &self, - permissions: Option<&[Permission]>, - expiry_seconds: Option, - ) -> crate::Result> { - match &self.client.token { - 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())) + /// # Errors + /// + /// This requires a [`TokenGenerator`](crate::TokenGenerator) to sign a new token. + /// If not available, this will result in an error. To retrieve a token with the current + /// session's default permissions and expiry, use [`get_token`](Self::get_token) instead. + pub fn create_token(&self) -> crate::Result>> { + match self.client.token { + Some(TokenProvider::Generator(ref generator)) => { + Ok(Some(generator.request_inner(&self.scope))) } + Some(TokenProvider::Static(_)) => Err(crate::Error::StaticTokenOverride), None => Ok(None), } } @@ -550,7 +549,7 @@ impl Session { } fn prepare_builder(&self, mut builder: RequestBuilder) -> crate::Result { - if let Some(token) = self.mint_token()? { + if let Some(token) = self.get_token()? { builder = builder.header("x-os-auth", format!("Bearer {token}")); } if self.client.propagate_traces { diff --git a/clients/rust/tests/e2e.rs b/clients/rust/tests/e2e.rs index c9724c42..8a9c9371 100644 --- a/clients/rust/tests/e2e.rs +++ b/clients/rust/tests/e2e.rs @@ -367,8 +367,8 @@ 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. +async fn create_token_rejects_escalation() { + // A read-only generator cannot create a token requesting write access. let client = Client::builder("http://127.0.0.1:8888/") .token(test_token_generator().permissions(&[Permission::ObjectRead])) .build() @@ -376,10 +376,12 @@ async fn mint_token_with_rejects_escalation() { 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, - ); + let result = session + .create_token() + .unwrap() + .expect("generator is configured") + .permissions(&[Permission::ObjectRead, Permission::ObjectWrite]) + .sign(); match result { Err(Error::PermissionEscalation { escalated }) => { assert_eq!(escalated, vec![Permission::ObjectWrite]); @@ -390,14 +392,18 @@ async fn mint_token_with_rejects_escalation() { // A subset of the granted permissions is accepted. assert!( session - .mint_token_with(Some(&[Permission::ObjectRead]), Some(30)) + .create_token() .unwrap() - .is_some() + .expect("generator is configured") + .permissions(&[Permission::ObjectRead]) + .expiry_seconds(30) + .sign() + .is_ok() ); } #[tokio::test] -async fn mint_token_with_rejects_static_override() { +async fn create_token_rejects_static_token() { let token = sign_static_token("usecase", &[("org", "12345")]); let client = Client::builder("http://127.0.0.1:8888/") .token(token) @@ -406,14 +412,25 @@ async fn mint_token_with_rejects_static_override() { let usecase = Usecase::new("usecase"); let session = client.session(usecase.for_organization(12345)).unwrap(); - // Overrides cannot be applied to a static, pre-signed token. + // A static, pre-signed token cannot be re-signed with custom permissions or expiry. assert!(matches!( - session.mint_token_with(Some(&[Permission::ObjectRead]), None), + session.create_token(), Err(Error::StaticTokenOverride) )); - // Without overrides, the static token is returned as-is. - assert!(session.mint_token().unwrap().is_some()); + // The static token itself is still returned as-is. + assert!(session.get_token().unwrap().is_some()); +} + +#[tokio::test] +async fn create_token_without_provider() { + let client = Client::builder("http://127.0.0.1:8888/").build().unwrap(); + let usecase = Usecase::new("usecase"); + let session = client.session(usecase.for_organization(12345)).unwrap(); + + // Without a token provider there is no request to configure, and no token to return. + assert!(session.create_token().unwrap().is_none()); + assert!(session.get_token().unwrap().is_none()); } #[tokio::test] From 52af6448607c9bcbc4a9b0d8f6bf7b0aaf016e51 Mon Sep 17 00:00:00 2001 From: Jan Michael Auer Date: Wed, 5 Aug 2026 09:41:07 +0200 Subject: [PATCH 3/4] fix: Lint --- clients/rust/src/auth.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/rust/src/auth.rs b/clients/rust/src/auth.rs index 79d9240e..6e571e98 100644 --- a/clients/rust/src/auth.rs +++ b/clients/rust/src/auth.rs @@ -159,7 +159,7 @@ impl TokenGenerator { self.create_token(scope).sign() } - /// Create a token for the given [`Scope`](crate::Scope). + /// Create a token for the given [`Scope`]. /// /// 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). From f3f8515ac5205a14cb8482e5823f859e1ad3c7b4 Mon Sep 17 00:00:00 2001 From: Jan Michael Auer Date: Wed, 5 Aug 2026 13:52:16 +0200 Subject: [PATCH 4/4] doc: Improve comment for get_token --- clients/rust/src/client.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/clients/rust/src/client.rs b/clients/rust/src/client.rs index 3bc6adca..6d9a1c0a 100644 --- a/clients/rust/src/client.rs +++ b/clients/rust/src/client.rs @@ -456,10 +456,21 @@ impl Session { url } - /// Returns a signed token if a token or generator was provided or `None` otherwise. + /// Returns a token authorizing access to this session's scope. /// - /// The token carries the configured provider's default permissions and expiry. Use - /// [`create_token`](Self::create_token) to customize these. + /// With a [`TokenGenerator`](crate::TokenGenerator), each call signs a fresh token whose expiry + /// starts now, using the configured permissions; use [`create_token`](Self::create_token) to + /// narrow either. + /// + /// If a pre-signed token was used to construct the client, it is returned verbatim, including + /// its original expiry. + /// + /// Returns `None` if the client was constructed without authentication. + /// + /// # Errors + /// + /// Returns an error if a new token cannot be signed with the configured key. Retrieving a + /// pre-signed token never fails. pub fn get_token(&self) -> crate::Result> { match self.client.token { Some(TokenProvider::Generator(ref generator)) => {