objectstore_server/auth/
context.rs

1use std::collections::{BTreeMap, HashSet};
2
3use jsonwebtoken::{Algorithm, Header, TokenData, Validation, decode, decode_header};
4use objectstore_service::id::ObjectContext;
5use objectstore_types::auth::Permission;
6use serde::{Deserialize, Serialize};
7
8use crate::auth::error::AuthError;
9use crate::auth::key_directory::PublicKeyDirectory;
10use crate::auth::util::StringOrWildcard;
11
12#[derive(Deserialize, Serialize, Debug, Clone)]
13struct JwtRes {
14    #[serde(rename = "os:usecase")]
15    usecase: String,
16
17    #[serde(flatten)]
18    scope: BTreeMap<String, StringOrWildcard>,
19}
20
21#[derive(Deserialize, Serialize, Debug, Clone)]
22struct JwtClaims {
23    res: JwtRes,
24    permissions: HashSet<Permission>,
25}
26
27fn jwt_validation_params(jwt_header: &Header) -> Validation {
28    let mut validation = Validation::new(jwt_header.alg);
29    validation.set_audience(&["objectstore"]);
30    validation.set_issuer(&["sentry", "relay"]);
31    validation.set_required_spec_claims(&["exp"]);
32    validation
33}
34
35/// `AuthContext` encapsulates the verified content of things like authorization tokens.
36///
37/// [`AuthContext::assert_authorized`] can be used to check whether a request is authorized to
38/// perform certain operations on a given resource.
39#[derive(Debug, PartialEq)]
40#[non_exhaustive]
41pub struct AuthContext {
42    /// The objectstore usecase that this request may act on.
43    ///
44    /// See also: [`ObjectContext::usecase`].
45    pub usecase: String,
46
47    /// The scope elements that this request may act on.
48    ///
49    /// See also: [`ObjectContext::scopes`].
50    pub scopes: BTreeMap<String, StringOrWildcard>,
51
52    /// The permissions that this request has been granted.
53    pub permissions: HashSet<Permission>,
54}
55
56impl AuthContext {
57    /// Construct an `AuthContext` from an encoded JWT.
58    ///
59    /// Objectstore JWTs _must_ contain:
60    /// - the `kid` header indicating which key was used to sign the token
61    /// - the `exp` claim indicating when the token expires
62    ///
63    /// The `aud` claim is not required, but if set it must be `"objectstore"`. The `iss` claim
64    /// is not required, but if set it must be `"relay"` or `"sentry"`.
65    ///
66    /// To verify the token, objectstore will look up a list of possible keys based on the `kid`
67    /// header field and attempt verification. It will also ensure that the timestamp from the
68    /// `exp` claim field has not passed.
69    pub fn from_encoded_jwt(
70        encoded_token: Option<&str>,
71        key_directory: &PublicKeyDirectory,
72    ) -> Result<AuthContext, AuthError> {
73        let encoded_token =
74            encoded_token.ok_or(AuthError::BadRequest("No authorization token provided"))?;
75
76        let jwt_header = decode_header(encoded_token)?;
77        let key_id = jwt_header
78            .kid
79            .as_ref()
80            .ok_or(AuthError::BadRequest("JWT header is missing `kid` field"))?;
81
82        let key_config = key_directory
83            .keys
84            .get(key_id)
85            .ok_or_else(|| AuthError::InternalError(format!("Key `{key_id}` not configured")))?;
86
87        if jwt_header.alg != Algorithm::EdDSA {
88            objectstore_log::warn!(
89                algorithm = ?jwt_header.alg,
90                "JWT signed with unexpected algorithm",
91            );
92            let kind = jsonwebtoken::errors::ErrorKind::InvalidAlgorithm;
93            return Err(AuthError::ValidationFailure(kind.into()));
94        }
95
96        let mut verified_claims: Option<TokenData<JwtClaims>> = None;
97        for decoding_key in &key_config.key_versions {
98            let decode_result = decode::<JwtClaims>(
99                encoded_token,
100                decoding_key,
101                &jwt_validation_params(&jwt_header),
102            );
103
104            // Handle retryable errors
105            use jsonwebtoken::errors::ErrorKind;
106            if decode_result
107                .as_ref()
108                .is_err_and(|err| err.kind() == &ErrorKind::InvalidSignature)
109            {
110                continue;
111            }
112
113            verified_claims = Some(decode_result?);
114            break;
115        }
116        let verified_claims = verified_claims.ok_or(AuthError::VerificationFailure)?;
117
118        let usecase = verified_claims.claims.res.usecase;
119        let scope = verified_claims.claims.res.scope;
120
121        // Taking the intersection here ensures the `AuthContext` does not have any permissions
122        // that `key_config.max_permissions` doesn't have, even if the token tried to grant them.
123        let permissions = verified_claims
124            .claims
125            .permissions
126            .intersection(&key_config.max_permissions)
127            .cloned()
128            .collect();
129
130        Ok(AuthContext {
131            usecase,
132            scopes: scope,
133            permissions,
134        })
135    }
136
137    /// Ensures that an operation requiring `perm` and applying to `path` is authorized. If not,
138    /// `Err(AuthError::NotPermitted)` is returned.
139    ///
140    /// The passed-in `perm` is checked against this `AuthContext`'s `permissions`. If it is not
141    /// present, then the operation is not authorized.
142    pub fn assert_authorized(
143        &self,
144        perm: Permission,
145        context: &ObjectContext,
146    ) -> Result<(), AuthError> {
147        if !self.permissions.contains(&perm) || self.usecase != context.usecase {
148            return Err(AuthError::NotPermitted);
149        }
150
151        for scope in &context.scopes {
152            let authorized = match self.scopes.get(scope.name()) {
153                Some(StringOrWildcard::String(s)) => s == scope.value(),
154                Some(StringOrWildcard::Wildcard) => true,
155                None => false,
156            };
157            if !authorized {
158                return Err(AuthError::NotPermitted);
159            }
160        }
161
162        Ok(())
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use crate::auth::PublicKeyConfig;
170    use jsonwebtoken::DecodingKey;
171    use objectstore_types::scope::{Scope, Scopes};
172    use serde_json::json;
173
174    use objectstore_test::server::{TEST_EDDSA_KID, TEST_EDDSA_PRIVKEY, TEST_EDDSA_PUBKEY};
175
176    #[derive(Serialize, Deserialize)]
177    struct TestJwtClaims {
178        exp: u64,
179        #[serde(flatten)]
180        claims: JwtClaims,
181    }
182
183    fn max_permission() -> HashSet<Permission> {
184        HashSet::from([
185            Permission::ObjectRead,
186            Permission::ObjectWrite,
187            Permission::ObjectDelete,
188        ])
189    }
190
191    fn test_key_config(max_permissions: HashSet<Permission>) -> PublicKeyDirectory {
192        let public_key = PublicKeyConfig {
193            key_versions: vec![DecodingKey::from_ed_pem(TEST_EDDSA_PUBKEY.as_bytes()).unwrap()],
194            max_permissions,
195        };
196        PublicKeyDirectory {
197            keys: BTreeMap::from([(TEST_EDDSA_KID.into(), public_key)]),
198        }
199    }
200
201    fn sign_token(claims: &JwtClaims, signing_secret: &str, exp: Option<u64>) -> String {
202        use jsonwebtoken::{Algorithm, EncodingKey, Header, encode, get_current_timestamp};
203
204        let mut header = Header::new(Algorithm::EdDSA);
205        header.kid = Some(TEST_EDDSA_KID.into());
206        header.typ = Some("JWT".into());
207
208        let claims = TestJwtClaims {
209            exp: exp.unwrap_or_else(|| get_current_timestamp() + 300),
210            claims: claims.clone(),
211        };
212
213        let key = EncodingKey::from_ed_pem(signing_secret.as_bytes()).unwrap();
214        encode(&header, &claims, &key).unwrap()
215    }
216
217    fn sample_claims(
218        org: &str,
219        proj: &str,
220        usecase: &str,
221        permissions: HashSet<Permission>,
222    ) -> JwtClaims {
223        serde_json::from_value(json!({
224            "res": {
225                "os:usecase": usecase,
226                "org": org,
227                "project": proj,
228            },
229            "permissions": permissions,
230        }))
231        .unwrap()
232    }
233
234    fn sample_auth_context(org: &str, proj: &str, permissions: HashSet<Permission>) -> AuthContext {
235        AuthContext {
236            usecase: "attachments".into(),
237            permissions,
238            scopes: serde_json::from_value(json!({"org": org, "project": proj})).unwrap(),
239        }
240    }
241
242    #[test]
243    fn test_from_encoded_jwt_basic() -> Result<(), AuthError> {
244        // Create a token with max permissions
245        let claims = sample_claims("123", "456", "attachments", max_permission());
246        let encoded_token = sign_token(&claims, &TEST_EDDSA_PRIVKEY, None);
247
248        // Create test config with max permissions
249        let test_config = test_key_config(max_permission());
250        let auth_context =
251            AuthContext::from_encoded_jwt(Some(encoded_token.as_str()), &test_config)?;
252
253        // Ensure the key is correctly verified and deserialized
254        let expected = sample_auth_context("123", "456", max_permission());
255        assert_eq!(auth_context, expected);
256
257        Ok(())
258    }
259
260    #[test]
261    fn test_from_encoded_jwt_max_permissions_limit() -> Result<(), AuthError> {
262        // Create a token with max permissions
263        let claims = sample_claims("123", "456", "attachments", max_permission());
264        let encoded_token = sign_token(&claims, &TEST_EDDSA_PRIVKEY, None);
265
266        // Assign read-only permissions to the signing key in config
267        let ro_permission = HashSet::from([Permission::ObjectRead]);
268        let test_config = test_key_config(ro_permission.clone());
269        let auth_context =
270            AuthContext::from_encoded_jwt(Some(encoded_token.as_str()), &test_config)?;
271
272        // Ensure the key is correctly verified and that the permissions are restricted
273        let expected = sample_auth_context("123", "456", ro_permission);
274        assert_eq!(auth_context, expected);
275
276        Ok(())
277    }
278
279    #[test]
280    fn test_from_encoded_jwt_invalid_token_fails() -> Result<(), AuthError> {
281        // Create a bogus token
282        let encoded_token = "abcdef";
283
284        // Create test config with max permissions
285        let test_config = test_key_config(max_permission());
286        let auth_context = AuthContext::from_encoded_jwt(Some(encoded_token), &test_config);
287
288        // Ensure the token failed verification
289        assert!(matches!(auth_context, Err(AuthError::ValidationFailure(_))));
290
291        Ok(())
292    }
293
294    #[test]
295    fn test_from_encoded_jwt_unknown_key_fails() -> Result<(), AuthError> {
296        let claims = sample_claims("123", "456", "attachments", max_permission());
297        let unknown_key = r#"-----BEGIN PRIVATE KEY-----
298MC4CAQAwBQYDK2VwBCIEIKwVoE4TmTfWoqH3HgLVsEcHs9PHNe+ar/Hp6e4To8pK
299-----END PRIVATE KEY-----
300"#;
301        let encoded_token = sign_token(&claims, unknown_key, None);
302
303        // Create test config with max permissions
304        let test_config = test_key_config(max_permission());
305        let auth_context =
306            AuthContext::from_encoded_jwt(Some(encoded_token.as_str()), &test_config);
307
308        // Ensure the token failed verification
309        assert!(matches!(auth_context, Err(AuthError::VerificationFailure)));
310
311        Ok(())
312    }
313
314    #[test]
315    fn test_from_encoded_jwt_expired() -> Result<(), AuthError> {
316        let claims = sample_claims("123", "456", "attachments", max_permission());
317        let encoded_token = sign_token(
318            &claims,
319            &TEST_EDDSA_PRIVKEY,
320            Some(jsonwebtoken::get_current_timestamp() - 100),
321        );
322
323        // Create test config with max permissions
324        let test_config = test_key_config(max_permission());
325        let auth_context =
326            AuthContext::from_encoded_jwt(Some(encoded_token.as_str()), &test_config);
327
328        // Ensure the token failed verification
329        let Err(AuthError::ValidationFailure(error)) = auth_context else {
330            panic!("auth must fail");
331        };
332        assert_eq!(
333            error.kind(),
334            &jsonwebtoken::errors::ErrorKind::ExpiredSignature
335        );
336
337        Ok(())
338    }
339
340    fn sample_object_context(org: &str, project: &str) -> ObjectContext {
341        ObjectContext {
342            usecase: "attachments".into(),
343            scopes: Scopes::from_iter([
344                Scope::create("org", org).unwrap(),
345                Scope::create("project", project).unwrap(),
346            ]),
347        }
348    }
349
350    // Allowed:
351    //   auth_context: org.123 / proj.123
352    //         object: org.123 / proj.123
353    #[test]
354    fn test_assert_authorized_exact_scope_allowed() -> Result<(), AuthError> {
355        let auth_context = sample_auth_context("123", "456", max_permission());
356        let object = sample_object_context("123", "456");
357
358        auth_context.assert_authorized(Permission::ObjectRead, &object)?;
359
360        Ok(())
361    }
362
363    // Allowed:
364    //   auth_context: org.123 / proj.*
365    //         object: org.123 / proj.123
366    #[test]
367    fn test_assert_authorized_wildcard_project_allowed() -> Result<(), AuthError> {
368        let auth_context = sample_auth_context("123", "*", max_permission());
369        let object = sample_object_context("123", "456");
370
371        auth_context.assert_authorized(Permission::ObjectRead, &object)?;
372
373        Ok(())
374    }
375
376    // Allowed:
377    //   auth_context: org.123 / proj.456
378    //         object: org.123
379    #[test]
380    fn test_assert_authorized_org_only_path_allowed() -> Result<(), AuthError> {
381        let auth_context = sample_auth_context("123", "456", max_permission());
382        let object = ObjectContext {
383            usecase: "attachments".into(),
384            scopes: Scopes::from_iter([Scope::create("org", "123").unwrap()]),
385        };
386
387        auth_context.assert_authorized(Permission::ObjectRead, &object)?;
388
389        Ok(())
390    }
391
392    // Not allowed:
393    //   auth_context: org.123 / proj.456
394    //         object: org.123 / proj.999
395    //
396    //   auth_context: org.123 / proj.456
397    //         object: org.999 / proj.456
398    #[test]
399    fn test_assert_authorized_scope_mismatch_fails() -> Result<(), AuthError> {
400        let auth_context = sample_auth_context("123", "456", max_permission());
401        let object = sample_object_context("123", "999");
402
403        let result = auth_context.assert_authorized(Permission::ObjectRead, &object);
404        assert_eq!(result, Err(AuthError::NotPermitted));
405
406        let auth_context = sample_auth_context("123", "456", max_permission());
407        let object = sample_object_context("999", "456");
408
409        let result = auth_context.assert_authorized(Permission::ObjectRead, &object);
410        assert_eq!(result, Err(AuthError::NotPermitted));
411
412        Ok(())
413    }
414
415    #[test]
416    fn test_assert_authorized_wrong_usecase_fails() -> Result<(), AuthError> {
417        let mut auth_context = sample_auth_context("123", "456", max_permission());
418        auth_context.usecase = "debug-files".into();
419        let object = sample_object_context("123", "456");
420
421        let result = auth_context.assert_authorized(Permission::ObjectRead, &object);
422        assert_eq!(result, Err(AuthError::NotPermitted));
423
424        Ok(())
425    }
426
427    #[test]
428    fn test_assert_authorized_auth_context_missing_permission_fails() -> Result<(), AuthError> {
429        let auth_context =
430            sample_auth_context("123", "456", HashSet::from([Permission::ObjectRead]));
431        let object = sample_object_context("123", "456");
432
433        let result = auth_context.assert_authorized(Permission::ObjectWrite, &object);
434        assert_eq!(result, Err(AuthError::NotPermitted));
435
436        Ok(())
437    }
438}