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
12 changes: 8 additions & 4 deletions clients/rust/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,19 +307,23 @@ 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: "<private 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 create a token with narrower permissions or a different expiry, use the
methods on the token request returned from `create_token`.

## Configuration

In production, store the [`Client`] and [`Usecase`] in a `static` and reuse them.
Expand Down
99 changes: 84 additions & 15 deletions clients/rust/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -153,33 +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<String> {
self.create_token(scope).sign()
}

/// 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).
///
/// 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,
}
}

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<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)
}
}

/// 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<Vec<Permission>>,
expiry_seconds: Option<u64>,
}

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 or the JWT cannot be signed.
pub fn sign(&self, scope: &crate::Scope) -> crate::Result<String> {
let scope = match &scope.0 {
pub fn sign(&self) -> crate::Result<String> {
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)
}

/// 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> {
let claims = JwtClaims {
exp: get_current_timestamp() + self.expiry_seconds,
permissions: self.permissions.clone(),
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
Expand All @@ -191,8 +260,8 @@ impl TokenGenerator {
};

let mut header = Header::new(Algorithm::EdDSA);
header.kid = Some(self.kid.clone());
header.kid = Some(self.generator.kid.clone());

Ok(encode(&header, &claims, &self.encoding_key)?)
Ok(encode(&header, &claims, &self.generator.encoding_key)?)
}
}
58 changes: 49 additions & 9 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::{TokenProvider, TokenRequest};

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

Expand Down Expand Up @@ -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};
Expand All @@ -360,7 +360,7 @@ pub(crate) struct ClientInner {
/// let token = TokenGenerator::new(SecretKey {
/// secret_key: "<private key>".into(),
/// kid: "my-service".into(),
/// })?.sign(&scope)?;
/// })?.create_token(&scope).sign()?;
///
/// let client = Client::builder("http://localhost:8888/")
/// .token(token)
Expand Down Expand Up @@ -456,13 +456,53 @@ 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.
///
/// 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<Option<String>> {
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<Option<String>> {
match &self.client.token {
Some(TokenProvider::Generator(generator)) => {
Ok(Some(generator.sign_for_scope(&self.scope)?))
self.get_token()
}

/// Creates a new token with configurable permissions and expiry.
///
/// Use this to produce a static token that can be handed to an external service
/// with lower permissions than the current session.
///
/// # 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<Option<TokenRequest<'_>>> {
Comment thread
lcian marked this conversation as resolved.
match self.client.token {
Some(TokenProvider::Generator(ref generator)) => {
Ok(Some(generator.request_inner(&self.scope)))
}
Some(TokenProvider::Static(token)) => Ok(Some(token.clone())),
Some(TokenProvider::Static(_)) => Err(crate::Error::StaticTokenOverride),
None => Ok(None),
}
}
Expand Down Expand Up @@ -520,7 +560,7 @@ impl Session {
}

fn prepare_builder(&self, mut builder: RequestBuilder) -> crate::Result<RequestBuilder> {
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 {
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
67 changes: 67 additions & 0 deletions clients/rust/tests/e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,73 @@ async fn fails_with_insufficient_auth_token_perms() {
}
}

#[tokio::test]
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()
.unwrap();
let usecase = Usecase::new("usecase");
let session = client.session(usecase.for_organization(12345)).unwrap();

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]);
}
other => panic!("Expected PermissionEscalation, got: {other:?}"),
}

// A subset of the granted permissions is accepted.
assert!(
session
.create_token()
.unwrap()
.expect("generator is configured")
.permissions(&[Permission::ObjectRead])
.expiry_seconds(30)
.sign()
.is_ok()
);
}

#[tokio::test]
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)
.build()
.unwrap();
let usecase = Usecase::new("usecase");
let session = client.session(usecase.for_organization(12345)).unwrap();

// A static, pre-signed token cannot be re-signed with custom permissions or expiry.
assert!(matches!(
session.create_token(),
Err(Error::StaticTokenOverride)
));

// 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]
async fn stores_with_static_token() {
let server = test_server().await;
Expand Down
Loading