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::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            tracing::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            tracing::debug!(?self, ?perm, ?context, "Authorization failed");
149            return Err(AuthError::NotPermitted);
150        }
151
152        for scope in &context.scopes {
153            let authorized = match self.scopes.get(scope.name()) {
154                Some(StringOrWildcard::String(s)) => s == scope.value(),
155                Some(StringOrWildcard::Wildcard) => true,
156                None => false,
157            };
158            if !authorized {
159                tracing::debug!(?self, ?perm, ?context, "Authorization failed");
160                return Err(AuthError::NotPermitted);
161            }
162        }
163
164        Ok(())
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use crate::auth::PublicKeyConfig;
172    use jsonwebtoken::DecodingKey;
173    use objectstore_types::scope::{Scope, Scopes};
174    use serde_json::json;
175
176    use objectstore_test::server::{TEST_EDDSA_KID, TEST_EDDSA_PRIVKEY, TEST_EDDSA_PUBKEY};
177
178    #[derive(Serialize, Deserialize)]
179    struct TestJwtClaims {
180        exp: u64,
181        #[serde(flatten)]
182        claims: JwtClaims,
183    }
184
185    fn max_permission() -> HashSet<Permission> {
186        HashSet::from([
187            Permission::ObjectRead,
188            Permission::ObjectWrite,
189            Permission::ObjectDelete,
190        ])
191    }
192
193    fn test_key_config(max_permissions: HashSet<Permission>) -> PublicKeyDirectory {
194        let public_key = PublicKeyConfig {
195            key_versions: vec![DecodingKey::from_ed_pem(TEST_EDDSA_PUBKEY.as_bytes()).unwrap()],
196            max_permissions,
197        };
198        PublicKeyDirectory {
199            keys: BTreeMap::from([(TEST_EDDSA_KID.into(), public_key)]),
200        }
201    }
202
203    fn sign_token(claims: &JwtClaims, signing_secret: &str, exp: Option<u64>) -> String {
204        use jsonwebtoken::{Algorithm, EncodingKey, Header, encode, get_current_timestamp};
205
206        let mut header = Header::new(Algorithm::EdDSA);
207        header.kid = Some(TEST_EDDSA_KID.into());
208        header.typ = Some("JWT".into());
209
210        let claims = TestJwtClaims {
211            exp: exp.unwrap_or_else(|| get_current_timestamp() + 300),
212            claims: claims.clone(),
213        };
214
215        let key = EncodingKey::from_ed_pem(signing_secret.as_bytes()).unwrap();
216        encode(&header, &claims, &key).unwrap()
217    }
218
219    fn sample_claims(
220        org: &str,
221        proj: &str,
222        usecase: &str,
223        permissions: HashSet<Permission>,
224    ) -> JwtClaims {
225        serde_json::from_value(json!({
226            "res": {
227                "os:usecase": usecase,
228                "org": org,
229                "project": proj,
230            },
231            "permissions": permissions,
232        }))
233        .unwrap()
234    }
235
236    fn sample_auth_context(org: &str, proj: &str, permissions: HashSet<Permission>) -> AuthContext {
237        AuthContext {
238            usecase: "attachments".into(),
239            permissions,
240            scopes: serde_json::from_value(json!({"org": org, "project": proj})).unwrap(),
241        }
242    }
243
244    #[test]
245    fn test_from_encoded_jwt_basic() -> Result<(), AuthError> {
246        // Create a token with max permissions
247        let claims = sample_claims("123", "456", "attachments", max_permission());
248        let encoded_token = sign_token(&claims, &TEST_EDDSA_PRIVKEY, None);
249
250        // Create test config with max permissions
251        let test_config = test_key_config(max_permission());
252        let auth_context =
253            AuthContext::from_encoded_jwt(Some(encoded_token.as_str()), &test_config)?;
254
255        // Ensure the key is correctly verified and deserialized
256        let expected = sample_auth_context("123", "456", max_permission());
257        assert_eq!(auth_context, expected);
258
259        Ok(())
260    }
261
262    #[test]
263    fn test_from_encoded_jwt_max_permissions_limit() -> Result<(), AuthError> {
264        // Create a token with max permissions
265        let claims = sample_claims("123", "456", "attachments", max_permission());
266        let encoded_token = sign_token(&claims, &TEST_EDDSA_PRIVKEY, None);
267
268        // Assign read-only permissions to the signing key in config
269        let ro_permission = HashSet::from([Permission::ObjectRead]);
270        let test_config = test_key_config(ro_permission.clone());
271        let auth_context =
272            AuthContext::from_encoded_jwt(Some(encoded_token.as_str()), &test_config)?;
273
274        // Ensure the key is correctly verified and that the permissions are restricted
275        let expected = sample_auth_context("123", "456", ro_permission);
276        assert_eq!(auth_context, expected);
277
278        Ok(())
279    }
280
281    #[test]
282    fn test_from_encoded_jwt_invalid_token_fails() -> Result<(), AuthError> {
283        // Create a bogus token
284        let encoded_token = "abcdef";
285
286        // Create test config with max permissions
287        let test_config = test_key_config(max_permission());
288        let auth_context = AuthContext::from_encoded_jwt(Some(encoded_token), &test_config);
289
290        // Ensure the token failed verification
291        assert!(matches!(auth_context, Err(AuthError::ValidationFailure(_))));
292
293        Ok(())
294    }
295
296    #[test]
297    fn test_from_encoded_jwt_unknown_key_fails() -> Result<(), AuthError> {
298        let claims = sample_claims("123", "456", "attachments", max_permission());
299        let unknown_key = r#"-----BEGIN PRIVATE KEY-----
300MC4CAQAwBQYDK2VwBCIEIKwVoE4TmTfWoqH3HgLVsEcHs9PHNe+ar/Hp6e4To8pK
301-----END PRIVATE KEY-----
302"#;
303        let encoded_token = sign_token(&claims, unknown_key, None);
304
305        // Create test config with max permissions
306        let test_config = test_key_config(max_permission());
307        let auth_context =
308            AuthContext::from_encoded_jwt(Some(encoded_token.as_str()), &test_config);
309
310        // Ensure the token failed verification
311        assert!(matches!(auth_context, Err(AuthError::VerificationFailure)));
312
313        Ok(())
314    }
315
316    #[test]
317    fn test_from_encoded_jwt_expired() -> Result<(), AuthError> {
318        let claims = sample_claims("123", "456", "attachments", max_permission());
319        let encoded_token = sign_token(
320            &claims,
321            &TEST_EDDSA_PRIVKEY,
322            Some(jsonwebtoken::get_current_timestamp() - 100),
323        );
324
325        // Create test config with max permissions
326        let test_config = test_key_config(max_permission());
327        let auth_context =
328            AuthContext::from_encoded_jwt(Some(encoded_token.as_str()), &test_config);
329
330        // Ensure the token failed verification
331        let Err(AuthError::ValidationFailure(error)) = auth_context else {
332            panic!("auth must fail");
333        };
334        assert_eq!(
335            error.kind(),
336            &jsonwebtoken::errors::ErrorKind::ExpiredSignature
337        );
338
339        Ok(())
340    }
341
342    fn sample_object_context(org: &str, project: &str) -> ObjectContext {
343        ObjectContext {
344            usecase: "attachments".into(),
345            scopes: Scopes::from_iter([
346                Scope::create("org", org).unwrap(),
347                Scope::create("project", project).unwrap(),
348            ]),
349        }
350    }
351
352    // Allowed:
353    //   auth_context: org.123 / proj.123
354    //         object: org.123 / proj.123
355    #[test]
356    fn test_assert_authorized_exact_scope_allowed() -> Result<(), AuthError> {
357        let auth_context = sample_auth_context("123", "456", max_permission());
358        let object = sample_object_context("123", "456");
359
360        auth_context.assert_authorized(Permission::ObjectRead, &object)?;
361
362        Ok(())
363    }
364
365    // Allowed:
366    //   auth_context: org.123 / proj.*
367    //         object: org.123 / proj.123
368    #[test]
369    fn test_assert_authorized_wildcard_project_allowed() -> Result<(), AuthError> {
370        let auth_context = sample_auth_context("123", "*", max_permission());
371        let object = sample_object_context("123", "456");
372
373        auth_context.assert_authorized(Permission::ObjectRead, &object)?;
374
375        Ok(())
376    }
377
378    // Allowed:
379    //   auth_context: org.123 / proj.456
380    //         object: org.123
381    #[test]
382    fn test_assert_authorized_org_only_path_allowed() -> Result<(), AuthError> {
383        let auth_context = sample_auth_context("123", "456", max_permission());
384        let object = ObjectContext {
385            usecase: "attachments".into(),
386            scopes: Scopes::from_iter([Scope::create("org", "123").unwrap()]),
387        };
388
389        auth_context.assert_authorized(Permission::ObjectRead, &object)?;
390
391        Ok(())
392    }
393
394    // Not allowed:
395    //   auth_context: org.123 / proj.456
396    //         object: org.123 / proj.999
397    //
398    //   auth_context: org.123 / proj.456
399    //         object: org.999 / proj.456
400    #[test]
401    fn test_assert_authorized_scope_mismatch_fails() -> Result<(), AuthError> {
402        let auth_context = sample_auth_context("123", "456", max_permission());
403        let object = sample_object_context("123", "999");
404
405        let result = auth_context.assert_authorized(Permission::ObjectRead, &object);
406        assert_eq!(result, Err(AuthError::NotPermitted));
407
408        let auth_context = sample_auth_context("123", "456", max_permission());
409        let object = sample_object_context("999", "456");
410
411        let result = auth_context.assert_authorized(Permission::ObjectRead, &object);
412        assert_eq!(result, Err(AuthError::NotPermitted));
413
414        Ok(())
415    }
416
417    #[test]
418    fn test_assert_authorized_wrong_usecase_fails() -> Result<(), AuthError> {
419        let mut auth_context = sample_auth_context("123", "456", max_permission());
420        auth_context.usecase = "debug-files".into();
421        let object = sample_object_context("123", "456");
422
423        let result = auth_context.assert_authorized(Permission::ObjectRead, &object);
424        assert_eq!(result, Err(AuthError::NotPermitted));
425
426        Ok(())
427    }
428
429    #[test]
430    fn test_assert_authorized_auth_context_missing_permission_fails() -> Result<(), AuthError> {
431        let auth_context =
432            sample_auth_context("123", "456", HashSet::from([Permission::ObjectRead]));
433        let object = sample_object_context("123", "456");
434
435        let result = auth_context.assert_authorized(Permission::ObjectWrite, &object);
436        assert_eq!(result, Err(AuthError::NotPermitted));
437
438        Ok(())
439    }
440}