1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
5#[serde(rename_all = "snake_case")]
6pub enum KeyClass {
7 Secret,
9 Publishable,
11}
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "kebab-case")]
16pub enum TargetKind {
17 Deployment,
19 ProgramReadBinding,
21 SolanaGatewayBinding,
23}
24
25#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
27pub struct Limits {
28 #[serde(skip_serializing_if = "Option::is_none")]
30 pub max_connections: Option<u32>,
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub max_subscriptions: Option<u32>,
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub max_snapshot_rows: Option<u32>,
37 #[serde(skip_serializing_if = "Option::is_none")]
39 pub max_messages_per_minute: Option<u32>,
40 #[serde(skip_serializing_if = "Option::is_none")]
42 pub max_bytes_per_minute: Option<u64>,
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub max_http_requests_per_minute: Option<u32>,
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub max_http_batch_addresses: Option<u32>,
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub max_transaction_inspect_requests_per_minute: Option<u32>,
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub max_transaction_send_requests_per_minute: Option<u32>,
55 #[serde(skip_serializing_if = "Option::is_none")]
57 pub max_transaction_status_requests_per_minute: Option<u32>,
58 #[serde(skip_serializing_if = "Option::is_none")]
60 pub max_transaction_request_bytes: Option<u32>,
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub max_transaction_bytes: Option<u32>,
64 #[serde(skip_serializing_if = "Option::is_none")]
66 pub max_transaction_concurrency: Option<u32>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub max_connection_attempts_per_minute: Option<u32>,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub max_subscription_creates_per_minute: Option<u32>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct SessionClaims {
78 pub iss: String,
80 pub sub: String,
82 pub aud: String,
84 pub iat: u64,
86 pub nbf: u64,
88 pub exp: u64,
90 pub jti: String,
92 pub scope: String,
94 pub metering_key: String,
96 #[serde(skip_serializing_if = "Option::is_none")]
98 pub deployment_id: Option<String>,
99 #[serde(
101 default,
102 rename = "targetKind",
103 skip_serializing_if = "Option::is_none"
104 )]
105 pub target_kind: Option<TargetKind>,
106 #[serde(default, rename = "targetId", skip_serializing_if = "Option::is_none")]
108 pub target_id: Option<String>,
109 #[serde(default, rename = "programId", skip_serializing_if = "Option::is_none")]
111 pub program_id: Option<String>,
112 #[serde(
114 default,
115 rename = "programReleaseHash",
116 skip_serializing_if = "Option::is_none"
117 )]
118 pub program_release_hash: Option<String>,
119 #[serde(skip_serializing_if = "Option::is_none")]
121 pub origin: Option<String>,
122 #[serde(skip_serializing_if = "Option::is_none", rename = "client_ip")]
124 pub client_ip: Option<String>,
125 #[serde(skip_serializing_if = "Option::is_none")]
127 pub limits: Option<Limits>,
128 #[serde(skip_serializing_if = "Option::is_none")]
130 pub plan: Option<String>,
131 #[serde(rename = "key_class")]
133 pub key_class: KeyClass,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub actor_key: Option<String>,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub account_key: Option<String>,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub consumer_key: Option<String>,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub policy_version: Option<u32>,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub account_limits: Option<Limits>,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub row_scopes: Option<Vec<String>>,
156}
157
158pub const MAX_POLICY_IDENTITY_BYTES: usize = 512;
160
161pub const PLAN_ANONYMOUS: &str = "anonymous";
163
164fn valid_policy_identity(value: &str) -> bool {
165 !value.is_empty()
166 && value.len() <= MAX_POLICY_IDENTITY_BYTES
167 && value.bytes().all(|byte| {
168 byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':' | b'@' | b'/')
169 })
170}
171
172pub(crate) fn resolve_policy_identity<'a>(explicit: Option<&'a str>, fallback: &'a str) -> &'a str {
173 explicit.unwrap_or(fallback)
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
178pub enum PolicyClaimsError {
179 #[error(
180 "invalid {0} identity: must be 1-{MAX_POLICY_IDENTITY_BYTES} bytes of [A-Za-z0-9._:@/-]"
181 )]
182 InvalidIdentity(&'static str),
183 #[error("incomplete policy claims: {0}")]
184 IncompleteClaims(&'static str),
185}
186
187impl SessionClaims {
188 pub fn builder(
190 iss: impl Into<String>,
191 sub: impl Into<String>,
192 aud: impl Into<String>,
193 ) -> SessionClaimsBuilder {
194 SessionClaimsBuilder::new(iss, sub, aud)
195 }
196
197 pub fn program_read_builder(
199 iss: impl Into<String>,
200 sub: impl Into<String>,
201 target_id: impl Into<String>,
202 program_id: impl Into<String>,
203 program_release_hash: impl Into<String>,
204 ) -> SessionClaimsBuilder {
205 SessionClaimsBuilder::new(iss, sub, crate::PROGRAM_READ_AUDIENCE).with_program_read_binding(
206 target_id,
207 program_id,
208 program_release_hash,
209 )
210 }
211
212 pub fn solana_gateway_builder(
214 iss: impl Into<String>,
215 sub: impl Into<String>,
216 target_id: impl Into<String>,
217 ) -> SessionClaimsBuilder {
218 SessionClaimsBuilder::new(iss, sub, crate::SOLANA_GATEWAY_AUDIENCE)
219 .with_solana_gateway_binding(target_id)
220 }
221
222 pub fn validate_policy_claims(&self) -> Result<(), PolicyClaimsError> {
232 for (name, value) in [
233 ("actor_key", &self.actor_key),
234 ("account_key", &self.account_key),
235 ("consumer_key", &self.consumer_key),
236 ] {
237 if let Some(value) = value {
238 if !valid_policy_identity(value) {
239 return Err(PolicyClaimsError::InvalidIdentity(name));
240 }
241 }
242 }
243
244 let has_any = self.actor_key.is_some()
245 || self.account_key.is_some()
246 || self.consumer_key.is_some()
247 || self.policy_version.is_some()
248 || self.account_limits.is_some();
249 if !has_any {
250 return Ok(());
251 }
252
253 let has_core = self.actor_key.is_some()
254 && self.consumer_key.is_some()
255 && self.policy_version.is_some();
256 if self.account_key.is_some() || self.account_limits.is_some() {
257 if !has_core || self.account_key.is_none() || self.account_limits.is_none() {
258 return Err(PolicyClaimsError::IncompleteClaims(
259 "account-scoped tokens require actor_key, account_key, consumer_key, \
260 policy_version, and account_limits together",
261 ));
262 }
263 return Ok(());
264 }
265
266 if !has_core {
267 return Err(PolicyClaimsError::IncompleteClaims(
268 "policy claims require actor_key, consumer_key, and policy_version together",
269 ));
270 }
271 if self.plan.as_deref() != Some(PLAN_ANONYMOUS) {
272 return Err(PolicyClaimsError::IncompleteClaims(
273 "tokens without account_key must declare the anonymous plan",
274 ));
275 }
276 Ok(())
277 }
278
279 pub fn is_expired(&self, now: u64) -> bool {
281 self.exp <= now
282 }
283
284 pub fn is_valid(&self, now: u64) -> bool {
286 self.nbf <= now && self.iat <= now
287 }
288}
289
290pub struct SessionClaimsBuilder {
292 iss: String,
293 sub: String,
294 aud: String,
295 iat: u64,
296 nbf: u64,
297 exp: u64,
298 jti: String,
299 scope: String,
300 metering_key: String,
301 deployment_id: Option<String>,
302 target_kind: Option<TargetKind>,
303 target_id: Option<String>,
304 program_id: Option<String>,
305 program_release_hash: Option<String>,
306 origin: Option<String>,
307 client_ip: Option<String>,
308 limits: Option<Limits>,
309 plan: Option<String>,
310 key_class: KeyClass,
311 actor_key: Option<String>,
312 account_key: Option<String>,
313 consumer_key: Option<String>,
314 policy_version: Option<u32>,
315 account_limits: Option<Limits>,
316 row_scopes: Option<Vec<String>>,
317}
318
319impl SessionClaimsBuilder {
320 fn new(iss: impl Into<String>, sub: impl Into<String>, aud: impl Into<String>) -> Self {
321 use std::time::{SystemTime, UNIX_EPOCH};
322 let now = SystemTime::now()
323 .duration_since(UNIX_EPOCH)
324 .expect("time should not be before epoch")
325 .as_secs();
326
327 Self {
328 iss: iss.into(),
329 sub: sub.into(),
330 aud: aud.into(),
331 iat: now,
332 nbf: now,
333 exp: now + crate::DEFAULT_SESSION_TTL_SECONDS,
334 jti: uuid::Uuid::new_v4().to_string(),
335 scope: crate::SCOPE_READ.to_string(),
336 metering_key: String::new(),
337 deployment_id: None,
338 target_kind: None,
339 target_id: None,
340 program_id: None,
341 program_release_hash: None,
342 origin: None,
343 client_ip: None,
344 limits: None,
345 plan: None,
346 key_class: KeyClass::Publishable,
347 actor_key: None,
348 account_key: None,
349 consumer_key: None,
350 policy_version: None,
351 account_limits: None,
352 row_scopes: None,
353 }
354 }
355
356 pub fn with_ttl(mut self, ttl_seconds: u64) -> Self {
357 self.exp = self.iat + ttl_seconds;
358 self
359 }
360
361 pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
362 self.scope = scope.into();
363 self
364 }
365
366 pub fn with_metering_key(mut self, key: impl Into<String>) -> Self {
367 self.metering_key = key.into();
368 self
369 }
370
371 pub fn with_deployment_id(mut self, id: impl Into<String>) -> Self {
372 self.deployment_id = Some(id.into());
373 self
374 }
375
376 pub fn with_target(mut self, kind: TargetKind, id: impl Into<String>) -> Self {
378 self.target_kind = Some(kind);
379 self.target_id = Some(id.into());
380 self
381 }
382
383 pub fn with_program_id(mut self, program_id: impl Into<String>) -> Self {
385 self.program_id = Some(program_id.into());
386 self
387 }
388
389 pub fn with_program_release_hash(mut self, hash: impl Into<String>) -> Self {
391 self.program_release_hash = Some(hash.into());
392 self
393 }
394
395 pub fn with_program_read_binding(
397 mut self,
398 target_id: impl Into<String>,
399 program_id: impl Into<String>,
400 program_release_hash: impl Into<String>,
401 ) -> Self {
402 self.aud = crate::PROGRAM_READ_AUDIENCE.to_string();
403 self.scope = crate::SCOPE_READ.to_string();
404 self.target_kind = Some(TargetKind::ProgramReadBinding);
405 self.target_id = Some(target_id.into());
406 self.program_id = Some(program_id.into());
407 self.program_release_hash = Some(program_release_hash.into());
408 self
409 }
410
411 pub fn with_solana_gateway_binding(mut self, target_id: impl Into<String>) -> Self {
413 self.aud = crate::SOLANA_GATEWAY_AUDIENCE.to_string();
414 self.scope = crate::SCOPE_READ.to_string();
415 self.target_kind = Some(TargetKind::SolanaGatewayBinding);
416 self.target_id = Some(target_id.into());
417 self
418 }
419
420 pub fn with_origin(mut self, origin: impl Into<String>) -> Self {
421 self.origin = Some(origin.into());
422 self
423 }
424
425 pub fn with_client_ip(mut self, client_ip: impl Into<String>) -> Self {
426 self.client_ip = Some(client_ip.into());
427 self
428 }
429
430 pub fn with_limits(mut self, limits: Limits) -> Self {
431 self.limits = Some(limits);
432 self
433 }
434
435 pub fn with_plan(mut self, plan: impl Into<String>) -> Self {
436 self.plan = Some(plan.into());
437 self
438 }
439
440 pub fn with_key_class(mut self, key_class: KeyClass) -> Self {
441 self.key_class = key_class;
442 self
443 }
444
445 pub fn with_jti(mut self, jti: impl Into<String>) -> Self {
446 self.jti = jti.into();
447 self
448 }
449
450 pub fn with_actor_key(mut self, actor_key: impl Into<String>) -> Self {
452 self.actor_key = Some(actor_key.into());
453 self
454 }
455
456 pub fn with_account_key(mut self, account_key: impl Into<String>) -> Self {
458 self.account_key = Some(account_key.into());
459 self
460 }
461
462 pub fn with_consumer_key(mut self, consumer_key: impl Into<String>) -> Self {
464 self.consumer_key = Some(consumer_key.into());
465 self
466 }
467
468 pub fn with_policy_version(mut self, policy_version: u32) -> Self {
470 self.policy_version = Some(policy_version);
471 self
472 }
473
474 pub fn with_account_limits(mut self, account_limits: Limits) -> Self {
476 self.account_limits = Some(account_limits);
477 self
478 }
479
480 pub fn with_row_scopes(
482 mut self,
483 row_scopes: impl IntoIterator<Item = impl Into<String>>,
484 ) -> Self {
485 self.row_scopes = Some(row_scopes.into_iter().map(Into::into).collect());
486 self
487 }
488
489 pub fn build(self) -> SessionClaims {
490 SessionClaims {
491 iss: self.iss,
492 sub: self.sub,
493 aud: self.aud,
494 iat: self.iat,
495 nbf: self.nbf,
496 exp: self.exp,
497 jti: self.jti,
498 scope: self.scope,
499 metering_key: self.metering_key,
500 deployment_id: self.deployment_id,
501 target_kind: self.target_kind,
502 target_id: self.target_id,
503 program_id: self.program_id,
504 program_release_hash: self.program_release_hash,
505 origin: self.origin,
506 client_ip: self.client_ip,
507 limits: self.limits,
508 plan: self.plan,
509 key_class: self.key_class,
510 actor_key: self.actor_key,
511 account_key: self.account_key,
512 consumer_key: self.consumer_key,
513 policy_version: self.policy_version,
514 account_limits: self.account_limits,
515 row_scopes: self.row_scopes,
516 }
517 }
518}
519
520#[derive(Debug, Clone)]
522pub struct AuthContext {
523 pub subject: String,
525 pub issuer: String,
527 pub audience: String,
529 pub key_class: KeyClass,
531 pub metering_key: String,
533 pub deployment_id: Option<String>,
535 pub target_kind: Option<TargetKind>,
537 pub target_id: Option<String>,
539 pub program_id: Option<String>,
541 pub program_release_hash: Option<String>,
543 pub expires_at: u64,
545 pub scope: String,
547 pub limits: Limits,
549 pub plan: Option<String>,
551 pub origin: Option<String>,
553 pub client_ip: Option<String>,
555 pub jti: String,
557 pub actor_key: Option<String>,
559 pub account_key: Option<String>,
561 pub consumer_key: Option<String>,
563 pub policy_version: Option<u32>,
565 pub account_limits: Limits,
567 pub row_scopes: Option<Vec<String>>,
570}
571
572impl AuthContext {
573 pub fn has_scope(&self, required: &str) -> bool {
575 self.scope.split_whitespace().any(|scope| scope == required)
576 }
577
578 pub fn actor_key(&self) -> &str {
580 resolve_policy_identity(self.actor_key.as_deref(), &self.subject)
581 }
582
583 pub fn consumer_key(&self) -> &str {
585 resolve_policy_identity(self.consumer_key.as_deref(), &self.subject)
586 }
587
588 pub fn account_key(&self) -> &str {
591 resolve_policy_identity(self.account_key.as_deref(), &self.metering_key)
592 }
593
594 pub fn is_legacy_policy(&self) -> bool {
597 self.actor_key.is_none()
598 && self.account_key.is_none()
599 && self.consumer_key.is_none()
600 && self.policy_version.is_none()
601 }
602
603 pub fn from_claims(claims: SessionClaims) -> Self {
605 Self {
606 subject: claims.sub,
607 issuer: claims.iss,
608 audience: claims.aud,
609 key_class: claims.key_class,
610 metering_key: claims.metering_key,
611 deployment_id: claims.deployment_id,
612 target_kind: claims.target_kind,
613 target_id: claims.target_id,
614 program_id: claims.program_id,
615 program_release_hash: claims.program_release_hash,
616 expires_at: claims.exp,
617 scope: claims.scope,
618 limits: claims.limits.unwrap_or_default(),
619 plan: claims.plan,
620 origin: claims.origin,
621 client_ip: claims.client_ip,
622 jti: claims.jti,
623 actor_key: claims.actor_key,
624 account_key: claims.account_key,
625 consumer_key: claims.consumer_key,
626 policy_version: claims.policy_version,
627 account_limits: claims.account_limits.unwrap_or_default(),
628 row_scopes: claims.row_scopes,
629 }
630 }
631}
632
633#[cfg(test)]
634mod tests {
635 use super::*;
636
637 #[test]
638 fn scopes_are_exact_and_independent() {
639 let context = AuthContext::from_claims(
640 SessionClaims::builder("issuer", "subject", "audience")
641 .with_scope("read transaction:inspect transaction:send-extra")
642 .build(),
643 );
644
645 assert!(context.has_scope("read"));
646 assert!(context.has_scope("transaction:inspect"));
647 assert!(!context.has_scope("transaction:send"));
648 assert!(!context.has_scope("transaction"));
649 }
650
651 #[test]
652 fn row_scopes_round_trip_and_stay_absent_when_unset() {
653 let plain = SessionClaims::builder("issuer", "subject", "audience").build();
654 let json = serde_json::to_value(&plain).unwrap();
655 assert!(json.get("row_scopes").is_none(), "{json}");
656 assert_eq!(AuthContext::from_claims(plain).row_scopes, None);
657
658 let scoped = SessionClaims::builder("issuer", "subject", "audience")
659 .with_scope("read")
660 .with_row_scopes(["abcdefghij"])
661 .build();
662 let json = serde_json::to_value(&scoped).unwrap();
663 assert_eq!(json["row_scopes"], serde_json::json!(["abcdefghij"]));
664 assert_eq!(json["scope"], "read");
666 let parsed: SessionClaims = serde_json::from_value(json).unwrap();
667 let context = AuthContext::from_claims(parsed);
668 assert_eq!(context.row_scopes, Some(vec!["abcdefghij".to_string()]));
669 }
670
671 #[test]
672 fn old_limits_claims_remain_deserializable() {
673 let limits: Limits = serde_json::from_value(serde_json::json!({
674 "max_connections": 2
675 }))
676 .unwrap();
677
678 assert_eq!(limits.max_connections, Some(2));
679 assert_eq!(limits.max_transaction_bytes, None);
680 }
681
682 #[test]
683 fn transaction_limits_round_trip_additively() {
684 let limits = Limits {
685 max_transaction_inspect_requests_per_minute: Some(120),
686 max_transaction_send_requests_per_minute: Some(12),
687 max_transaction_status_requests_per_minute: Some(240),
688 max_transaction_request_bytes: Some(4096),
689 max_transaction_bytes: Some(1232),
690 max_transaction_concurrency: Some(4),
691 ..Limits::default()
692 };
693 let value = serde_json::to_value(&limits).unwrap();
694 let decoded: Limits = serde_json::from_value(value).unwrap();
695
696 assert_eq!(decoded.max_transaction_bytes, Some(1232));
697 assert_eq!(decoded.max_transaction_concurrency, Some(4));
698 }
699
700 #[test]
701 fn program_read_claims_use_camel_case_fields() {
702 let claims = SessionClaims::program_read_builder(
703 "issuer",
704 "subject",
705 "binding-1",
706 "program-1",
707 "release-1",
708 )
709 .build();
710 let value = serde_json::to_value(claims).unwrap();
711
712 assert_eq!(value["aud"], crate::PROGRAM_READ_AUDIENCE);
713 assert_eq!(value["targetKind"], "program-read-binding");
714 assert_eq!(value["targetId"], "binding-1");
715 assert_eq!(value["programId"], "program-1");
716 assert_eq!(value["programReleaseHash"], "release-1");
717 assert!(value.get("target_kind").is_none());
718 }
719
720 #[test]
721 fn gateway_claims_use_stable_audience_target_and_default_scope() {
722 let claims =
723 SessionClaims::solana_gateway_builder("issuer", "subject", "gateway-us-east-1").build();
724 let value = serde_json::to_value(claims).unwrap();
725
726 assert_eq!(value["aud"], crate::SOLANA_GATEWAY_AUDIENCE);
727 assert_eq!(value["targetKind"], "solana-gateway-binding");
728 assert_eq!(value["targetId"], "gateway-us-east-1");
729 assert_eq!(value["scope"], crate::SCOPE_READ);
730 }
731
732 fn account_limits() -> Limits {
733 Limits {
734 max_connections: Some(50),
735 max_messages_per_minute: Some(50_000),
736 max_connection_attempts_per_minute: Some(600),
737 max_subscription_creates_per_minute: Some(1_200),
738 ..Limits::default()
739 }
740 }
741
742 fn v2_builder() -> SessionClaimsBuilder {
743 SessionClaims::builder("issuer", "user:1", "deployment-1")
744 .with_metering_key("account:42")
745 .with_plan("pro")
746 .with_actor_key("user:1")
747 .with_account_key("account:42")
748 .with_consumer_key("consumer:abc123")
749 .with_policy_version(7)
750 .with_account_limits(account_limits())
751 }
752
753 #[test]
754 fn golden_old_token_json_still_deserializes_and_resolves() {
755 let claims: SessionClaims = serde_json::from_value(serde_json::json!({
757 "iss": "issuer",
758 "sub": "user:1",
759 "aud": "deployment-1",
760 "iat": 1, "nbf": 1, "exp": 2, "jti": "jti-1",
761 "scope": "read",
762 "metering_key": "api_key:42",
763 "limits": { "max_connections": 2 },
764 "plan": "starter",
765 "key_class": "publishable"
766 }))
767 .unwrap();
768
769 claims.validate_policy_claims().unwrap();
770 let context = AuthContext::from_claims(claims);
771
772 assert!(context.is_legacy_policy());
773 assert_eq!(context.actor_key(), "user:1");
774 assert_eq!(context.consumer_key(), "user:1");
775 assert_eq!(context.account_key(), "api_key:42");
776 assert_eq!(context.account_limits, Limits::default());
777 assert_eq!(context.limits.max_connections, Some(2));
778 }
779
780 #[test]
781 fn tokens_built_without_new_methods_serialize_to_the_old_shape() {
782 let claims = SessionClaims::builder("issuer", "user:1", "deployment-1")
783 .with_metering_key("api_key:42")
784 .build();
785 let value = serde_json::to_value(claims).unwrap();
786
787 for absent in [
788 "actor_key",
789 "account_key",
790 "consumer_key",
791 "policy_version",
792 "account_limits",
793 ] {
794 assert!(value.get(absent).is_none(), "{absent} must be absent");
795 }
796 }
797
798 #[test]
799 fn v2_claims_round_trip_with_exact_snake_case_wire_keys() {
800 let claims = v2_builder().build();
801 claims.validate_policy_claims().unwrap();
802 let value = serde_json::to_value(&claims).unwrap();
803
804 assert_eq!(value["actor_key"], "user:1");
805 assert_eq!(value["account_key"], "account:42");
806 assert_eq!(value["consumer_key"], "consumer:abc123");
807 assert_eq!(value["policy_version"], 7);
808 assert_eq!(value["account_limits"]["max_connections"], 50);
809 assert_eq!(
810 value["account_limits"]["max_connection_attempts_per_minute"],
811 600
812 );
813 assert_eq!(
814 value["account_limits"]["max_subscription_creates_per_minute"],
815 1200
816 );
817
818 let decoded: SessionClaims = serde_json::from_value(value).unwrap();
819 assert_eq!(decoded.actor_key.as_deref(), Some("user:1"));
820 assert_eq!(decoded.policy_version, Some(7));
821 assert_eq!(decoded.account_limits, Some(account_limits()));
822
823 let context = AuthContext::from_claims(decoded);
824 assert!(!context.is_legacy_policy());
825 assert_eq!(context.actor_key(), "user:1");
826 assert_eq!(context.consumer_key(), "consumer:abc123");
827 assert_eq!(context.account_key(), "account:42");
828 assert_eq!(context.policy_version, Some(7));
829 assert_eq!(context.account_limits, account_limits());
830 }
831
832 #[test]
833 fn anonymous_v2_claims_require_the_anonymous_plan_and_no_account() {
834 let anonymous = SessionClaims::builder("issuer", "anon:ip-1", "deployment-1")
835 .with_metering_key("anon:ip-1")
836 .with_plan(PLAN_ANONYMOUS)
837 .with_actor_key("anon:ip-1")
838 .with_consumer_key("consumer:abc123")
839 .with_policy_version(3)
840 .build();
841 anonymous.validate_policy_claims().unwrap();
842
843 let wrong_plan = SessionClaims::builder("issuer", "anon:ip-1", "deployment-1")
844 .with_plan("pro")
845 .with_actor_key("anon:ip-1")
846 .with_consumer_key("consumer:abc123")
847 .with_policy_version(3)
848 .build();
849 assert!(matches!(
850 wrong_plan.validate_policy_claims(),
851 Err(PolicyClaimsError::IncompleteClaims(_))
852 ));
853 }
854
855 #[test]
856 fn partial_v2_identity_subsets_are_rejected() {
857 let missing_consumer = SessionClaims::builder("issuer", "user:1", "deployment-1")
859 .with_actor_key("user:1")
860 .with_account_key("account:42")
861 .with_policy_version(1)
862 .with_account_limits(Limits::default())
863 .build();
864 assert!(matches!(
865 missing_consumer.validate_policy_claims(),
866 Err(PolicyClaimsError::IncompleteClaims(_))
867 ));
868
869 let limits_only = SessionClaims::builder("issuer", "user:1", "deployment-1")
871 .with_account_limits(Limits::default())
872 .build();
873 assert!(limits_only.validate_policy_claims().is_err());
874
875 let version_only = SessionClaims::builder("issuer", "user:1", "deployment-1")
877 .with_policy_version(1)
878 .build();
879 assert!(version_only.validate_policy_claims().is_err());
880
881 let missing_limits = SessionClaims::builder("issuer", "user:1", "deployment-1")
883 .with_actor_key("user:1")
884 .with_account_key("account:42")
885 .with_consumer_key("consumer:abc123")
886 .with_policy_version(1)
887 .build();
888 assert!(missing_limits.validate_policy_claims().is_err());
889 }
890
891 #[test]
892 fn malformed_or_oversized_identities_are_rejected() {
893 let empty = v2_builder().with_consumer_key("").build();
894 assert_eq!(
895 empty.validate_policy_claims(),
896 Err(PolicyClaimsError::InvalidIdentity("consumer_key"))
897 );
898
899 let oversized = v2_builder()
900 .with_account_key("a".repeat(MAX_POLICY_IDENTITY_BYTES + 1))
901 .build();
902 assert_eq!(
903 oversized.validate_policy_claims(),
904 Err(PolicyClaimsError::InvalidIdentity("account_key"))
905 );
906
907 let bad_charset = v2_builder().with_actor_key("user 1\n").build();
908 assert_eq!(
909 bad_charset.validate_policy_claims(),
910 Err(PolicyClaimsError::InvalidIdentity("actor_key"))
911 );
912
913 let boundary = v2_builder()
914 .with_account_key("a".repeat(MAX_POLICY_IDENTITY_BYTES))
915 .build();
916 assert!(boundary.validate_policy_claims().is_ok());
917 }
918
919 #[test]
920 fn legacy_deployment_claims_remain_untyped() {
921 let claims = SessionClaims::builder("issuer", "subject", "deployment-1")
922 .with_deployment_id("deployment-1")
923 .build();
924 let value = serde_json::to_value(&claims).unwrap();
925 let decoded: SessionClaims = serde_json::from_value(value.clone()).unwrap();
926
927 assert_eq!(decoded.deployment_id.as_deref(), Some("deployment-1"));
928 assert_eq!(decoded.target_kind, None);
929 assert!(value.get("targetKind").is_none());
930 }
931}