Skip to main content

objectstore_client/
auth.rs

1use std::collections::{BTreeMap, HashSet};
2
3use jsonwebtoken::{Algorithm, EncodingKey, Header, encode, get_current_timestamp};
4use objectstore_types::scope;
5use serde::{Deserialize, Serialize};
6
7use crate::{Scope, ScopeInner};
8
9pub use objectstore_types::auth::Permission;
10
11const DEFAULT_EXPIRY_SECONDS: u64 = 60;
12const DEFAULT_PERMISSIONS: [Permission; 3] = [
13    Permission::ObjectRead,
14    Permission::ObjectWrite,
15    Permission::ObjectDelete,
16];
17
18/// Key configuration that will be used to sign tokens in Objectstore requests.
19pub struct SecretKey {
20    /// A key ID that Objectstore must use to load the corresponding public key.
21    pub kid: String,
22
23    /// An EdDSA private key.
24    pub secret_key: String,
25}
26
27impl std::fmt::Debug for SecretKey {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        f.debug_struct("SecretKey")
30            .field("kid", &self.kid)
31            .field("secret_key", &"[redacted]")
32            .finish()
33    }
34}
35
36/// Authentication provider for Objectstore requests.
37///
38/// Can be either a [`TokenGenerator`] that signs a fresh JWT per request,
39/// or a static pre-signed JWT string.
40pub enum TokenProvider {
41    /// A pre-signed JWT token string, used as-is for every request.
42    Static(String),
43    /// A generator that signs a fresh JWT for each request using an EdDSA keypair.
44    Generator(TokenGenerator),
45}
46
47impl std::fmt::Debug for TokenProvider {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        match self {
50            TokenProvider::Static(_) => f.write_str("TokenProvider::Static([redacted])"),
51            TokenProvider::Generator(g) => {
52                f.debug_tuple("TokenProvider::Generator").field(g).finish()
53            }
54        }
55    }
56}
57
58/// Conversion into an optional [`TokenProvider`] for [`ClientBuilder::token`].
59///
60/// This is implemented for [`TokenGenerator`], `String`, and `&str`, each of which yields a
61/// configured provider. It is also implemented for any `Option<T>` where `T: IntoTokenProvider`,
62/// so a `None` resolves to no authentication and a `Some(value)` to the inner provider. This lets
63/// callers pass optional auth configuration to [`ClientBuilder::token`] without an explicit
64/// conditional.
65///
66/// [`ClientBuilder::token`]: crate::ClientBuilder::token
67pub trait IntoTokenProvider {
68    /// Converts `self` into an optional [`TokenProvider`].
69    fn into_token_provider(self) -> Option<TokenProvider>;
70}
71
72impl<T> IntoTokenProvider for Option<T>
73where
74    T: IntoTokenProvider,
75{
76    fn into_token_provider(self) -> Option<TokenProvider> {
77        self.and_then(|t| t.into_token_provider())
78    }
79}
80
81impl IntoTokenProvider for TokenGenerator {
82    fn into_token_provider(self) -> Option<TokenProvider> {
83        Some(TokenProvider::Generator(self))
84    }
85}
86
87impl IntoTokenProvider for String {
88    fn into_token_provider(self) -> Option<TokenProvider> {
89        Some(TokenProvider::Static(self))
90    }
91}
92
93impl IntoTokenProvider for &str {
94    fn into_token_provider(self) -> Option<TokenProvider> {
95        Some(TokenProvider::Static(self.to_owned()))
96    }
97}
98
99/// A utility to generate auth tokens to be used in Objectstore requests.
100///
101/// Tokens are signed with an EdDSA private key and have certain permissions and expiry timeouts
102/// applied.
103///
104/// Use this for internal services that have access to an EdDSA keypair. A `TokenGenerator`
105/// implements [`IntoTokenProvider`], so it can be passed directly to
106/// [`ClientBuilder::token`](crate::ClientBuilder::token), where it becomes a
107/// [`TokenProvider::Generator`].
108#[derive(Debug)]
109pub struct TokenGenerator {
110    kid: String,
111    encoding_key: EncodingKey,
112    expiry_seconds: u64,
113    permissions: HashSet<Permission>,
114}
115
116#[derive(Serialize, Deserialize)]
117struct JwtRes {
118    #[serde(rename = "os:usecase")]
119    usecase: String,
120
121    #[serde(flatten)]
122    scopes: BTreeMap<String, String>,
123}
124
125#[derive(Serialize, Deserialize)]
126struct JwtClaims {
127    exp: u64,
128    permissions: HashSet<Permission>,
129    res: JwtRes,
130}
131
132impl TokenGenerator {
133    /// Create a new [`TokenGenerator`] for a given key configuration.
134    pub fn new(secret_key: SecretKey) -> crate::Result<TokenGenerator> {
135        let encoding_key = EncodingKey::from_ed_pem(secret_key.secret_key.as_bytes())?;
136        Ok(TokenGenerator {
137            kid: secret_key.kid,
138            encoding_key,
139            expiry_seconds: DEFAULT_EXPIRY_SECONDS,
140            permissions: HashSet::from(DEFAULT_PERMISSIONS),
141        })
142    }
143
144    /// Set the expiry duration for tokens signed by this generator.
145    pub fn expiry_seconds(mut self, expiry_seconds: u64) -> Self {
146        self.expiry_seconds = expiry_seconds;
147        self
148    }
149
150    /// Set the permissions that will be granted to tokens signed by this generator.
151    pub fn permissions(mut self, permissions: &[Permission]) -> Self {
152        self.permissions = HashSet::from_iter(permissions.iter().copied());
153        self
154    }
155
156    /// Deprecated. Use [`create_token`](Self::create_token) instead.
157    #[deprecated(note = "Use `create_token(scope).sign()` instead")]
158    pub fn sign(&self, scope: &Scope) -> crate::Result<String> {
159        self.create_token(scope).sign()
160    }
161
162    /// Create a token for the given [`Scope`].
163    ///
164    /// Use this to produce a static token that can be handed to an external service
165    /// which then passes it to [`ClientBuilder::token`](crate::ClientBuilder::token).
166    ///
167    /// By default, the token is signed with the generator's default permissions and expiry.
168    /// Use the builder methods on the returned [`TokenRequest`] to customize them.
169    pub fn create_token<'a>(&'a self, scope: &'a Scope) -> TokenRequest<'a> {
170        TokenRequest {
171            generator: self,
172            scope: scope.0.as_ref(),
173            permissions: None,
174            expiry_seconds: None,
175        }
176    }
177
178    pub(crate) fn request_inner<'a>(&'a self, scope: &'a ScopeInner) -> TokenRequest<'a> {
179        TokenRequest {
180            generator: self,
181            scope: Ok(scope),
182            permissions: None,
183            expiry_seconds: None,
184        }
185    }
186
187    /// Resolves the permissions to embed in a token, validating that any explicitly requested
188    /// permissions are a subset of those granted to this generator.
189    fn resolve_permissions(
190        &self,
191        requested: Option<&[Permission]>,
192    ) -> crate::Result<HashSet<Permission>> {
193        let Some(requested) = requested else {
194            return Ok(self.permissions.clone());
195        };
196
197        let requested: HashSet<Permission> = requested.iter().copied().collect();
198        let mut escalated: Vec<Permission> =
199            requested.difference(&self.permissions).copied().collect();
200        if !escalated.is_empty() {
201            escalated.sort_by_key(Permission::to_string);
202            return Err(crate::Error::PermissionEscalation { escalated });
203        }
204        Ok(requested)
205    }
206}
207
208/// A request to mint a new token returned from [`TokenGenerator::create_token`].
209#[derive(Debug)]
210pub struct TokenRequest<'a> {
211    generator: &'a TokenGenerator,
212    scope: Result<&'a ScopeInner, &'a crate::Error>,
213    permissions: Option<Vec<Permission>>,
214    expiry_seconds: Option<u64>,
215}
216
217impl TokenRequest<'_> {
218    /// Override the permissions for this token, which must be a subset of the generator's
219    /// permissions.
220    pub fn permissions(mut self, permissions: &[Permission]) -> Self {
221        self.permissions = Some(permissions.to_vec());
222        self
223    }
224
225    /// Set the expiry duration for this token, overriding the generator's default.
226    pub fn expiry_seconds(mut self, expiry_seconds: u64) -> Self {
227        self.expiry_seconds = Some(expiry_seconds);
228        self
229    }
230
231    /// Finalizes and signs the token, returning the JWT string.
232    ///
233    /// # Errors
234    ///
235    /// Returns an error if the scope is invalid or the JWT cannot be signed.
236    pub fn sign(&self) -> crate::Result<String> {
237        let scope = match self.scope {
238            Ok(inner) => inner,
239            Err(crate::Error::InvalidScope(err)) => return Err(err.clone().into()),
240            // Return an ad-hoc `Unreachable` variant to avoid panicking.
241            // It should be impossible to run into a different error variant other than
242            // `InvalidScope`, unless we add a new variant and forget to update this code path.
243            _ => return Err(scope::InvalidScopeError::Unreachable.into()),
244        };
245
246        let claims = JwtClaims {
247            exp: get_current_timestamp()
248                + self.expiry_seconds.unwrap_or(self.generator.expiry_seconds),
249            permissions: self
250                .generator
251                .resolve_permissions(self.permissions.as_deref())?,
252            res: JwtRes {
253                usecase: scope.usecase().name().into(),
254                scopes: scope
255                    .scopes()
256                    .iter()
257                    .map(|scope| (scope.name().to_string(), scope.value().to_string()))
258                    .collect(),
259            },
260        };
261
262        let mut header = Header::new(Algorithm::EdDSA);
263        header.kid = Some(self.generator.kid.clone());
264
265        Ok(encode(&header, &claims, &self.generator.encoding_key)?)
266    }
267}