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::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    /// Sign a token for the given [`Scope`](crate::Scope), returning the JWT string.
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    /// # Errors
162    ///
163    /// Returns an error if the scope is invalid or the JWT cannot be signed.
164    pub fn sign(&self, scope: &crate::Scope) -> crate::Result<String> {
165        let scope = match &scope.0 {
166            Ok(inner) => inner,
167            Err(crate::Error::InvalidScope(err)) => {
168                return Err(err.clone().into());
169            }
170            // Return an ad-hoc `Unreachable` variant to avoid panicking.
171            // It should be impossible to run into a different error variant other than
172            // `InvalidScope`, unless we add a new variant and forget to update this code path.
173            _ => return Err(scope::InvalidScopeError::Unreachable.into()),
174        };
175        self.sign_for_scope(scope)
176    }
177
178    /// Sign a new token for the passed-in scope using the configured expiry and permissions.
179    pub(crate) fn sign_for_scope(&self, scope: &ScopeInner) -> crate::Result<String> {
180        let claims = JwtClaims {
181            exp: get_current_timestamp() + self.expiry_seconds,
182            permissions: self.permissions.clone(),
183            res: JwtRes {
184                usecase: scope.usecase().name().into(),
185                scopes: scope
186                    .scopes()
187                    .iter()
188                    .map(|scope| (scope.name().to_string(), scope.value().to_string()))
189                    .collect(),
190            },
191        };
192
193        let mut header = Header::new(Algorithm::EdDSA);
194        header.kid = Some(self.kid.clone());
195
196        Ok(encode(&header, &claims, &self.encoding_key)?)
197    }
198}