diff --git a/clients/rust/README.md b/clients/rust/README.md index 8de5ae21..e5147cf6 100644 --- a/clients/rust/README.md +++ b/clients/rust/README.md @@ -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: "".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. diff --git a/clients/rust/src/auth.rs b/clients/rust/src/auth.rs index ae043ebf..6e571e98 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,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 { + 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> { + 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 or the JWT cannot be signed. - pub fn sign(&self, scope: &crate::Scope) -> crate::Result { - let scope = match &scope.0 { + 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) - } - /// 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 { 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 @@ -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)?) } } diff --git a/clients/rust/src/client.rs b/clients/rust/src/client.rs index 3f34785d..6d9a1c0a 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::{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) @@ -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> { + 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> { - 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>> { + 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), } } @@ -520,7 +560,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/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..8a9c9371 100644 --- a/clients/rust/tests/e2e.rs +++ b/clients/rust/tests/e2e.rs @@ -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;