Skip to main content

objectstore_server/auth/
context.rs

1use std::collections::{BTreeMap, HashSet};
2use std::time::{Duration, SystemTime};
3
4use http::Method;
5use jsonwebtoken::{Algorithm, Header, TokenData, Validation, decode, decode_header};
6use objectstore_service::id::ObjectContext;
7use objectstore_types::auth::Permission;
8use objectstore_types::presign::CanonicalRequest;
9use objectstore_types::usecase::Usecase;
10use serde::{Deserialize, Serialize};
11
12use crate::auth::KeyId;
13use crate::auth::error::AuthError;
14use crate::auth::key_directory::PublicKeyDirectory;
15use crate::auth::util::StringOrWildcard;
16
17#[derive(Deserialize, Serialize, Debug, Clone)]
18struct JwtRes {
19    #[serde(rename = "os:usecase")]
20    usecase: Usecase,
21
22    #[serde(flatten)]
23    scope: BTreeMap<String, StringOrWildcard>,
24}
25
26#[derive(Deserialize, Serialize, Debug, Clone)]
27struct JwtClaims {
28    res: JwtRes,
29    permissions: HashSet<Permission>,
30}
31
32fn jwt_validation_params(jwt_header: &Header) -> Validation {
33    let mut validation = Validation::new(jwt_header.alg);
34    validation.set_audience(&["objectstore"]);
35    validation.set_issuer(&["sentry", "relay"]);
36    validation.set_required_spec_claims(&["exp"]);
37    validation
38}
39
40/// The verified authorization details obtained from a JWT.
41#[derive(Debug, PartialEq)]
42#[non_exhaustive]
43pub struct ScopedContext {
44    /// The objectstore usecase that this request may act on.
45    ///
46    /// See also: [`ObjectContext::usecase`].
47    pub usecase: Usecase,
48
49    /// The scope elements that this request may act on.
50    ///
51    /// See also: [`ObjectContext::scopes`].
52    pub scopes: BTreeMap<String, StringOrWildcard>,
53
54    /// The permissions that this request has been granted.
55    pub permissions: HashSet<Permission>,
56}
57
58/// Maximum duration for a pre-signed URL.
59const MAX_PRESIGN_DURATION: Duration = Duration::from_secs(7 * 24 * 60 * 60); // 7 days
60
61/// The pre-signing query parameters.
62#[derive(Debug, Deserialize)]
63pub struct PresignParams {
64    /// Key ID identifying which signing key was used.
65    #[serde(rename = "os_kid")]
66    pub key_id: KeyId,
67    /// Base64url-encoded Ed25519 signature.
68    #[serde(rename = "os_sig")]
69    pub signature: String,
70    /// RFC 3339 timestamp of when the URL was signed.
71    #[serde(rename = "os_timestamp", with = "humantime_serde")]
72    pub timestamp: SystemTime,
73    /// Validity duration in seconds from `timestamp`.
74    #[serde(rename = "os_duration")]
75    pub duration_secs: u64,
76}
77
78/// `AuthContext` encapsulates the verified authorization details of a request.
79///
80/// [`AuthContext::assert_authorized`] can be used to check whether a request is authorized to
81/// perform certain operations on a given resource.
82#[derive(Debug, PartialEq)]
83pub enum AuthContext {
84    /// Authorization is inactive; every operation is permitted.
85    Disabled,
86    /// A verified JWT; each operation is checked against these scopes and permissions.
87    Scoped(ScopedContext),
88    /// A valid signature already authorized this exact request.
89    Preauthorized,
90}
91
92impl AuthContext {
93    /// Construct an `AuthContext` from an encoded JWT.
94    ///
95    /// Objectstore JWTs _must_ contain:
96    /// - the `kid` header indicating which key was used to sign the token
97    /// - the `exp` claim indicating when the token expires
98    ///
99    /// The `aud` claim is not required, but if set it must be `"objectstore"`. The `iss` claim
100    /// is not required, but if set it must be `"relay"` or `"sentry"`.
101    ///
102    /// To verify the token, objectstore will look up a list of possible keys based on the `kid`
103    /// header field and attempt verification. It will also ensure that the timestamp from the
104    /// `exp` claim field has not passed.
105    pub fn from_encoded_jwt(
106        encoded_token: Option<&str>,
107        key_directory: &PublicKeyDirectory,
108    ) -> Result<AuthContext, AuthError> {
109        let encoded_token =
110            encoded_token.ok_or(AuthError::BadRequest("No authorization token provided"))?;
111
112        let jwt_header = decode_header(encoded_token)?;
113        let key_id = jwt_header
114            .kid
115            .as_ref()
116            .ok_or(AuthError::BadRequest("JWT header is missing `kid` field"))?;
117
118        let key_config = key_directory
119            .keys
120            .get(key_id)
121            .ok_or(AuthError::UnknownKey)?;
122
123        if jwt_header.alg != Algorithm::EdDSA {
124            objectstore_log::warn!(
125                algorithm = ?jwt_header.alg,
126                "JWT signed with unexpected algorithm",
127            );
128            let kind = jsonwebtoken::errors::ErrorKind::InvalidAlgorithm;
129            return Err(AuthError::ValidationFailure(kind.into()));
130        }
131
132        let mut verified_claims: Option<TokenData<JwtClaims>> = None;
133        for key_version in &key_config.key_versions {
134            let decode_result = decode::<JwtClaims>(
135                encoded_token,
136                &key_version.decoding_key,
137                &jwt_validation_params(&jwt_header),
138            );
139
140            // Handle retryable errors
141            use jsonwebtoken::errors::ErrorKind;
142            if decode_result
143                .as_ref()
144                .is_err_and(|err| err.kind() == &ErrorKind::InvalidSignature)
145            {
146                continue;
147            }
148
149            verified_claims = Some(decode_result?);
150            break;
151        }
152        let verified_claims = verified_claims.ok_or(AuthError::VerificationFailure)?;
153
154        let usecase = verified_claims.claims.res.usecase;
155        let scope = verified_claims.claims.res.scope;
156
157        // Taking the intersection here ensures the `AuthContext` does not have any permissions
158        // that `key_config.max_permissions` doesn't have, even if the token tried to grant them.
159        let permissions = verified_claims
160            .claims
161            .permissions
162            .intersection(&key_config.max_permissions)
163            .copied()
164            .collect();
165
166        Ok(AuthContext::Scoped(ScopedContext {
167            usecase,
168            scopes: scope,
169            permissions,
170        }))
171    }
172
173    /// Construct an `AuthContext` from a pre-signed request.
174    ///
175    /// A pre-signed URL carries its signature and parameters in the query string (see
176    /// [`objectstore_types::presign`]). This verifies the signature against the request's
177    /// canonical form and enforces the maximum duration, returning [`AuthContext::Preauthorized`]
178    /// on success.
179    pub fn from_presigned_request(
180        method: &Method,
181        path: &str,
182        raw_query: Option<&str>,
183        params: &PresignParams,
184        key_directory: &PublicKeyDirectory,
185        now: SystemTime,
186    ) -> Result<AuthContext, AuthError> {
187        let key_config = key_directory
188            .keys
189            .get(&params.key_id)
190            .ok_or(AuthError::UnknownKey)?;
191
192        let duration = Duration::from_secs(params.duration_secs);
193        if duration > MAX_PRESIGN_DURATION {
194            return Err(AuthError::BadRequest(
195                "presigned URL validity exceeds the maximum of 1 week",
196            ));
197        }
198
199        let start = params
200            .timestamp
201            // Subtract 60 secs to account for possible clock skew.
202            .checked_sub(Duration::from_secs(60))
203            .ok_or(AuthError::VerificationFailure)?;
204        let end = params
205            .timestamp
206            .checked_add(duration)
207            .ok_or(AuthError::VerificationFailure)?;
208        if now < start || now > end {
209            return Err(AuthError::VerificationFailure);
210        }
211
212        let canonical = CanonicalRequest::new(method, path, raw_query);
213
214        let verified = key_config.key_versions.iter().any(|key| {
215            canonical
216                .verify(key.verifying_key.as_bytes(), &params.signature)
217                .is_ok()
218        });
219        if !verified {
220            return Err(AuthError::VerificationFailure);
221        }
222
223        // Pre-signed URLs currently only support read operations (GET/HEAD).
224        if !key_config.max_permissions.contains(&Permission::ObjectRead) {
225            return Err(AuthError::NotPermitted);
226        }
227
228        Ok(AuthContext::Preauthorized)
229    }
230
231    /// Ensures that an operation requiring `perm` and applying to `path` is authorized. If not,
232    /// `Err(AuthError::NotPermitted)` is returned.
233    ///
234    /// - [`AuthContext::Disabled`] permits every operation.
235    /// - [`AuthContext::Scoped`] permits the operation if `perm` is within the granted permissions
236    ///   and usecase and scopes match the granted ones.
237    /// - [`AuthContext::Preauthorized`] always permits the operation — the signing key's
238    ///   permissions were already verified when the pre-signed URL was validated.
239    pub fn assert_authorized(
240        &self,
241        perm: Permission,
242        context: &ObjectContext,
243    ) -> Result<(), AuthError> {
244        let scoped = match self {
245            AuthContext::Disabled => return Ok(()),
246            AuthContext::Preauthorized =>
247            // Pre-signed URLs currently only support read operations (GET/HEAD).
248            {
249                return if perm == Permission::ObjectRead {
250                    Ok(())
251                } else {
252                    Err(AuthError::NotPermitted)
253                };
254            }
255            AuthContext::Scoped(scoped) => scoped,
256        };
257
258        if !scoped.permissions.contains(&perm) || scoped.usecase != context.usecase {
259            return Err(AuthError::NotPermitted);
260        }
261
262        for scope in &context.scopes {
263            let authorized = match scoped.scopes.get(scope.name()) {
264                Some(StringOrWildcard::String(s)) => s == scope.value(),
265                Some(StringOrWildcard::Wildcard) => true,
266                None => false,
267            };
268            if !authorized {
269                return Err(AuthError::NotPermitted);
270            }
271        }
272
273        Ok(())
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use crate::auth::{PublicKey, PublicKeyConfig};
281    use ed25519_dalek::pkcs8::{DecodePrivateKey, DecodePublicKey};
282    use ed25519_dalek::{SigningKey, VerifyingKey};
283    use jsonwebtoken::DecodingKey;
284    use objectstore_types::presign::{
285        CanonicalRequest, PARAM_DURATION, PARAM_KID, PARAM_SIG, PARAM_TIMESTAMP,
286    };
287    use objectstore_types::scope::{Scope, Scopes};
288    use serde_json::json;
289
290    use objectstore_test::server::{TEST_EDDSA_KID, TEST_EDDSA_PRIVKEY, TEST_EDDSA_PUBKEY};
291
292    #[derive(Serialize, Deserialize)]
293    struct TestJwtClaims {
294        exp: u64,
295        #[serde(flatten)]
296        claims: JwtClaims,
297    }
298
299    fn max_permission() -> HashSet<Permission> {
300        HashSet::from([
301            Permission::ObjectRead,
302            Permission::ObjectWrite,
303            Permission::ObjectDelete,
304        ])
305    }
306
307    fn test_key_config(max_permissions: HashSet<Permission>) -> PublicKeyDirectory {
308        let public_key = PublicKeyConfig {
309            key_versions: vec![PublicKey {
310                decoding_key: DecodingKey::from_ed_pem(TEST_EDDSA_PUBKEY.as_bytes()).unwrap(),
311                verifying_key: VerifyingKey::from_public_key_pem(TEST_EDDSA_PUBKEY).unwrap(),
312            }],
313            max_permissions,
314        };
315        PublicKeyDirectory {
316            keys: BTreeMap::from([(TEST_EDDSA_KID.into(), public_key)]),
317        }
318    }
319
320    fn sign_token(claims: &JwtClaims, signing_secret: &str, exp: Option<u64>) -> String {
321        use jsonwebtoken::{Algorithm, EncodingKey, Header, encode, get_current_timestamp};
322
323        let mut header = Header::new(Algorithm::EdDSA);
324        header.kid = Some(TEST_EDDSA_KID.into());
325        header.typ = Some("JWT".into());
326
327        let claims = TestJwtClaims {
328            exp: exp.unwrap_or_else(|| get_current_timestamp() + 300),
329            claims: claims.clone(),
330        };
331
332        let key = EncodingKey::from_ed_pem(signing_secret.as_bytes()).unwrap();
333        encode(&header, &claims, &key).unwrap()
334    }
335
336    fn sample_claims(
337        org: &str,
338        proj: &str,
339        usecase: &str,
340        permissions: HashSet<Permission>,
341    ) -> JwtClaims {
342        serde_json::from_value(json!({
343            "res": {
344                "os:usecase": usecase,
345                "org": org,
346                "project": proj,
347            },
348            "permissions": permissions,
349        }))
350        .unwrap()
351    }
352
353    fn sample_auth_context(org: &str, proj: &str, permissions: HashSet<Permission>) -> AuthContext {
354        AuthContext::Scoped(ScopedContext {
355            usecase: "attachments".parse().unwrap(),
356            permissions,
357            scopes: serde_json::from_value(json!({"org": org, "project": proj})).unwrap(),
358        })
359    }
360
361    #[test]
362    fn test_from_encoded_jwt_basic() -> Result<(), AuthError> {
363        // Create a token with max permissions
364        let claims = sample_claims("123", "456", "attachments", max_permission());
365        let encoded_token = sign_token(&claims, TEST_EDDSA_PRIVKEY, None);
366
367        // Create test config with max permissions
368        let test_config = test_key_config(max_permission());
369        let auth_context =
370            AuthContext::from_encoded_jwt(Some(encoded_token.as_str()), &test_config)?;
371
372        // Ensure the key is correctly verified and deserialized
373        let expected = sample_auth_context("123", "456", max_permission());
374        assert_eq!(auth_context, expected);
375
376        Ok(())
377    }
378
379    #[test]
380    fn test_from_encoded_jwt_max_permissions_limit() -> Result<(), AuthError> {
381        // Create a token with max permissions
382        let claims = sample_claims("123", "456", "attachments", max_permission());
383        let encoded_token = sign_token(&claims, TEST_EDDSA_PRIVKEY, None);
384
385        // Assign read-only permissions to the signing key in config
386        let ro_permission = HashSet::from([Permission::ObjectRead]);
387        let test_config = test_key_config(ro_permission.clone());
388        let auth_context =
389            AuthContext::from_encoded_jwt(Some(encoded_token.as_str()), &test_config)?;
390
391        // Ensure the key is correctly verified and that the permissions are restricted
392        let expected = sample_auth_context("123", "456", ro_permission);
393        assert_eq!(auth_context, expected);
394
395        Ok(())
396    }
397
398    #[test]
399    fn test_from_encoded_jwt_invalid_token_fails() -> Result<(), AuthError> {
400        // Create a bogus token
401        let encoded_token = "abcdef";
402
403        // Create test config with max permissions
404        let test_config = test_key_config(max_permission());
405        let auth_context = AuthContext::from_encoded_jwt(Some(encoded_token), &test_config);
406
407        // Ensure the token failed verification
408        assert!(matches!(auth_context, Err(AuthError::ValidationFailure(_))));
409
410        Ok(())
411    }
412
413    #[test]
414    fn test_from_encoded_jwt_unknown_key_fails() -> Result<(), AuthError> {
415        let claims = sample_claims("123", "456", "attachments", max_permission());
416        let unknown_key = r#"-----BEGIN PRIVATE KEY-----
417MC4CAQAwBQYDK2VwBCIEIKwVoE4TmTfWoqH3HgLVsEcHs9PHNe+ar/Hp6e4To8pK
418-----END PRIVATE KEY-----
419"#;
420        let encoded_token = sign_token(&claims, unknown_key, None);
421
422        // Create test config with max permissions
423        let test_config = test_key_config(max_permission());
424        let auth_context =
425            AuthContext::from_encoded_jwt(Some(encoded_token.as_str()), &test_config);
426
427        // Ensure the token failed verification
428        assert!(matches!(auth_context, Err(AuthError::VerificationFailure)));
429
430        Ok(())
431    }
432
433    #[test]
434    fn test_from_encoded_jwt_expired() -> Result<(), AuthError> {
435        let claims = sample_claims("123", "456", "attachments", max_permission());
436        let encoded_token = sign_token(
437            &claims,
438            TEST_EDDSA_PRIVKEY,
439            Some(jsonwebtoken::get_current_timestamp() - 100),
440        );
441
442        // Create test config with max permissions
443        let test_config = test_key_config(max_permission());
444        let auth_context =
445            AuthContext::from_encoded_jwt(Some(encoded_token.as_str()), &test_config);
446
447        // Ensure the token failed verification
448        let Err(AuthError::ValidationFailure(error)) = auth_context else {
449            panic!("auth must fail");
450        };
451        assert_eq!(
452            error.kind(),
453            &jsonwebtoken::errors::ErrorKind::ExpiredSignature
454        );
455
456        Ok(())
457    }
458
459    fn sample_object_context(org: &str, project: &str) -> ObjectContext {
460        ObjectContext {
461            usecase: "attachments".parse().unwrap(),
462            scopes: Scopes::from_iter([
463                Scope::create("org", org).unwrap(),
464                Scope::create("project", project).unwrap(),
465            ]),
466        }
467    }
468
469    // Allowed:
470    //   auth_context: org.123 / proj.123
471    //         object: org.123 / proj.123
472    #[test]
473    fn test_assert_authorized_exact_scope_allowed() -> Result<(), AuthError> {
474        let auth_context = sample_auth_context("123", "456", max_permission());
475        let object = sample_object_context("123", "456");
476
477        auth_context.assert_authorized(Permission::ObjectRead, &object)?;
478
479        Ok(())
480    }
481
482    // Allowed:
483    //   auth_context: org.123 / proj.*
484    //         object: org.123 / proj.123
485    #[test]
486    fn test_assert_authorized_wildcard_project_allowed() -> Result<(), AuthError> {
487        let auth_context = sample_auth_context("123", "*", max_permission());
488        let object = sample_object_context("123", "456");
489
490        auth_context.assert_authorized(Permission::ObjectRead, &object)?;
491
492        Ok(())
493    }
494
495    // Allowed:
496    //   auth_context: org.123 / proj.456
497    //         object: org.123
498    #[test]
499    fn test_assert_authorized_org_only_path_allowed() -> Result<(), AuthError> {
500        let auth_context = sample_auth_context("123", "456", max_permission());
501        let object = ObjectContext {
502            usecase: "attachments".parse().unwrap(),
503            scopes: Scopes::from_iter([Scope::create("org", "123").unwrap()]),
504        };
505
506        auth_context.assert_authorized(Permission::ObjectRead, &object)?;
507
508        Ok(())
509    }
510
511    // Not allowed:
512    //   auth_context: org.123 / proj.456
513    //         object: org.123 / proj.999
514    //
515    //   auth_context: org.123 / proj.456
516    //         object: org.999 / proj.456
517    #[test]
518    fn test_assert_authorized_scope_mismatch_fails() -> Result<(), AuthError> {
519        let auth_context = sample_auth_context("123", "456", max_permission());
520        let object = sample_object_context("123", "999");
521
522        let result = auth_context.assert_authorized(Permission::ObjectRead, &object);
523        assert_eq!(result, Err(AuthError::NotPermitted));
524
525        let auth_context = sample_auth_context("123", "456", max_permission());
526        let object = sample_object_context("999", "456");
527
528        let result = auth_context.assert_authorized(Permission::ObjectRead, &object);
529        assert_eq!(result, Err(AuthError::NotPermitted));
530
531        Ok(())
532    }
533
534    #[test]
535    fn test_assert_authorized_wrong_usecase_fails() -> Result<(), AuthError> {
536        let AuthContext::Scoped(mut scoped) = sample_auth_context("123", "456", max_permission())
537        else {
538            panic!("expected a scoped auth context");
539        };
540        scoped.usecase = "debug-files".parse().unwrap();
541        let auth_context = AuthContext::Scoped(scoped);
542        let object = sample_object_context("123", "456");
543
544        let result = auth_context.assert_authorized(Permission::ObjectRead, &object);
545        assert_eq!(result, Err(AuthError::NotPermitted));
546
547        Ok(())
548    }
549
550    #[test]
551    fn test_assert_authorized_auth_context_missing_permission_fails() -> Result<(), AuthError> {
552        let auth_context =
553            sample_auth_context("123", "456", HashSet::from([Permission::ObjectRead]));
554        let object = sample_object_context("123", "456");
555
556        let result = auth_context.assert_authorized(Permission::ObjectWrite, &object);
557        assert_eq!(result, Err(AuthError::NotPermitted));
558
559        Ok(())
560    }
561
562    #[test]
563    fn test_auth_context_from_presigned() {
564        let key_directory = test_key_config(HashSet::from([Permission::ObjectRead]));
565
566        let path = "/v1/objects/test/org=1/key";
567        let timestamp = humantime::format_rfc3339(SystemTime::now()).to_string();
568        let base = format!(
569            "{PARAM_KID}={TEST_EDDSA_KID}&{PARAM_TIMESTAMP}={timestamp}&{PARAM_DURATION}=3600"
570        );
571
572        let signing_key = SigningKey::from_pkcs8_pem(TEST_EDDSA_PRIVKEY).unwrap();
573        let canonical = CanonicalRequest::new(&Method::GET, path, Some(&base));
574        let signature = canonical.sign(signing_key.as_bytes());
575        let query = format!("{base}&{PARAM_SIG}={signature}");
576
577        let params = PresignParams {
578            signature,
579            key_id: TEST_EDDSA_KID.to_string(),
580            timestamp: SystemTime::now(),
581            duration_secs: 3600,
582        };
583
584        let context = AuthContext::from_presigned_request(
585            &Method::GET,
586            path,
587            Some(&query),
588            &params,
589            &key_directory,
590            SystemTime::now(),
591        )
592        .unwrap();
593
594        assert_eq!(context, AuthContext::Preauthorized);
595    }
596
597    #[test]
598    fn test_presigned_request_rejected_without_read_permission() {
599        let key_directory = test_key_config(HashSet::from([
600            Permission::ObjectWrite,
601            Permission::ObjectDelete,
602        ]));
603
604        let path = "/v1/objects/test/org=1/key";
605        let timestamp = humantime::format_rfc3339(SystemTime::now()).to_string();
606        let base = format!(
607            "{PARAM_KID}={TEST_EDDSA_KID}&{PARAM_TIMESTAMP}={timestamp}&{PARAM_DURATION}=3600"
608        );
609
610        let signing_key = SigningKey::from_pkcs8_pem(TEST_EDDSA_PRIVKEY).unwrap();
611        let canonical = CanonicalRequest::new(&Method::GET, path, Some(&base));
612        let signature = canonical.sign(signing_key.as_bytes());
613        let query = format!("{base}&{PARAM_SIG}={signature}");
614
615        let params = PresignParams {
616            signature,
617            key_id: TEST_EDDSA_KID.to_string(),
618            timestamp: SystemTime::now(),
619            duration_secs: 3600,
620        };
621
622        let result = AuthContext::from_presigned_request(
623            &Method::GET,
624            path,
625            Some(&query),
626            &params,
627            &key_directory,
628            SystemTime::now(),
629        );
630
631        assert_eq!(result, Err(AuthError::NotPermitted));
632    }
633}