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#[derive(Debug, PartialEq)]
42#[non_exhaustive]
43pub struct ScopedContext {
44 pub usecase: Usecase,
48
49 pub scopes: BTreeMap<String, StringOrWildcard>,
53
54 pub permissions: HashSet<Permission>,
56}
57
58const MAX_PRESIGN_DURATION: Duration = Duration::from_secs(7 * 24 * 60 * 60); #[derive(Debug, Deserialize)]
63pub struct PresignParams {
64 #[serde(rename = "os_kid")]
66 pub key_id: KeyId,
67 #[serde(rename = "os_sig")]
69 pub signature: String,
70 #[serde(rename = "os_timestamp", with = "humantime_serde")]
72 pub timestamp: SystemTime,
73 #[serde(rename = "os_duration")]
75 pub duration_secs: u64,
76}
77
78#[derive(Debug, PartialEq)]
83pub enum AuthContext {
84 Disabled,
86 Scoped(ScopedContext),
88 Preauthorized,
90}
91
92impl AuthContext {
93 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 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 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 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(¶ms.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 .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(), ¶ms.signature)
217 .is_ok()
218 });
219 if !verified {
220 return Err(AuthError::VerificationFailure);
221 }
222
223 if !key_config.max_permissions.contains(&Permission::ObjectRead) {
225 return Err(AuthError::NotPermitted);
226 }
227
228 Ok(AuthContext::Preauthorized)
229 }
230
231 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 {
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 let claims = sample_claims("123", "456", "attachments", max_permission());
365 let encoded_token = sign_token(&claims, TEST_EDDSA_PRIVKEY, None);
366
367 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 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 let claims = sample_claims("123", "456", "attachments", max_permission());
383 let encoded_token = sign_token(&claims, TEST_EDDSA_PRIVKEY, None);
384
385 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 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 let encoded_token = "abcdef";
402
403 let test_config = test_key_config(max_permission());
405 let auth_context = AuthContext::from_encoded_jwt(Some(encoded_token), &test_config);
406
407 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 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 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 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 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 #[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 #[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 #[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 #[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 ¶ms,
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 ¶ms,
627 &key_directory,
628 SystemTime::now(),
629 );
630
631 assert_eq!(result, Err(AuthError::NotPermitted));
632 }
633}