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