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    /// Create a token for the given [`Scope`].
157    ///
158    /// Use this to produce a static token that can be handed to an external service
159    /// which then passes it to [`ClientBuilder::token`](crate::ClientBuilder::token).
160    ///
161    /// By default, the token is signed with the generator's default permissions and expiry.
162    /// Use the builder methods on the returned [`TokenRequest`] to customize them.
163    pub fn create_token<'a>(&'a self, scope: &'a Scope) -> TokenRequest<'a> {
164        TokenRequest {
165            generator: self,
166            scope: scope.0.as_ref(),
167            permissions: None,
168            expiry_seconds: None,
169        }
170    }
171
172    pub(crate) fn request_inner<'a>(&'a self, scope: &'a ScopeInner) -> TokenRequest<'a> {
173        TokenRequest {
174            generator: self,
175            scope: Ok(scope),
176            permissions: None,
177            expiry_seconds: None,
178        }
179    }
180
181    /// Resolves the permissions to embed in a token, validating that any explicitly requested
182    /// permissions are a subset of those granted to this generator.
183    fn resolve_permissions(
184        &self,
185        requested: Option<&[Permission]>,
186    ) -> crate::Result<HashSet<Permission>> {
187        let Some(requested) = requested else {
188            return Ok(self.permissions.clone());
189        };
190
191        let requested: HashSet<Permission> = requested.iter().copied().collect();
192        let mut escalated: Vec<Permission> =
193            requested.difference(&self.permissions).copied().collect();
194        if !escalated.is_empty() {
195            escalated.sort_by_key(Permission::to_string);
196            return Err(crate::Error::PermissionEscalation { escalated });
197        }
198        Ok(requested)
199    }
200}
201
202/// A request to mint a new token returned from [`TokenGenerator::create_token`].
203#[derive(Debug)]
204pub struct TokenRequest<'a> {
205    generator: &'a TokenGenerator,
206    scope: Result<&'a ScopeInner, &'a crate::Error>,
207    permissions: Option<Vec<Permission>>,
208    expiry_seconds: Option<u64>,
209}
210
211impl TokenRequest<'_> {
212    /// Override the permissions for this token, which must be a subset of the generator's
213    /// permissions.
214    pub fn permissions(mut self, permissions: &[Permission]) -> Self {
215        self.permissions = Some(permissions.to_vec());
216        self
217    }
218
219    /// Set the expiry duration for this token, overriding the generator's default.
220    pub fn expiry_seconds(mut self, expiry_seconds: u64) -> Self {
221        self.expiry_seconds = Some(expiry_seconds);
222        self
223    }
224
225    /// Finalizes and signs the token, returning the JWT string.
226    ///
227    /// # Errors
228    ///
229    /// Returns an error if the scope is invalid or the JWT cannot be signed.
230    pub fn sign(&self) -> crate::Result<String> {
231        let scope = match self.scope {
232            Ok(inner) => inner,
233            Err(crate::Error::InvalidScope(err)) => return Err(err.clone().into()),
234            // Return an ad-hoc `Unreachable` variant to avoid panicking.
235            // It should be impossible to run into a different error variant other than
236            // `InvalidScope`, unless we add a new variant and forget to update this code path.
237            _ => return Err(scope::InvalidScopeError::Unreachable.into()),
238        };
239
240        let claims = JwtClaims {
241            exp: get_current_timestamp()
242                + self.expiry_seconds.unwrap_or(self.generator.expiry_seconds),
243            permissions: self
244                .generator
245                .resolve_permissions(self.permissions.as_deref())?,
246            res: JwtRes {
247                usecase: scope.usecase().name().into(),
248                scopes: scope
249                    .scopes()
250                    .iter()
251                    .map(|scope| (scope.name().to_string(), scope.value().to_string()))
252                    .collect(),
253            },
254        };
255
256        let mut header = Header::new(Algorithm::EdDSA);
257        header.kid = Some(self.generator.kid.clone());
258
259        Ok(encode(&header, &claims, &self.generator.encoding_key)?)
260    }
261}