Skip to main content

arete_auth/
claims.rs

1use serde::{Deserialize, Serialize};
2
3/// Key classification for metering and policy
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
5#[serde(rename_all = "snake_case")]
6pub enum KeyClass {
7    /// Secret API key - long-lived, high trust
8    Secret,
9    /// Publishable key - safe for browsers, constrained
10    Publishable,
11}
12
13/// Kind of resource targeted by a signed session.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "kebab-case")]
16pub enum TargetKind {
17    /// A legacy stack deployment.
18    Deployment,
19    /// A hosted program-read binding.
20    ProgramReadBinding,
21    /// A regional Solana RPC gateway binding.
22    SolanaGatewayBinding,
23}
24
25/// Resource limits for a session
26#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
27pub struct Limits {
28    /// Maximum concurrent connections for this subject
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub max_connections: Option<u32>,
31    /// Maximum subscriptions per connection
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub max_subscriptions: Option<u32>,
34    /// Maximum snapshot rows per request
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub max_snapshot_rows: Option<u32>,
37    /// Maximum messages per minute
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub max_messages_per_minute: Option<u32>,
40    /// Maximum egress bytes per minute
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub max_bytes_per_minute: Option<u64>,
43    /// Maximum HTTP read requests per minute
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub max_http_requests_per_minute: Option<u32>,
46    /// Maximum account addresses accepted in one HTTP batch read
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub max_http_batch_addresses: Option<u32>,
49    /// Maximum transaction inspection requests per minute
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub max_transaction_inspect_requests_per_minute: Option<u32>,
52    /// Maximum transaction submissions per minute
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub max_transaction_send_requests_per_minute: Option<u32>,
55    /// Maximum signature status requests per minute
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub max_transaction_status_requests_per_minute: Option<u32>,
58    /// Maximum encoded HTTP request body size for transaction routes
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub max_transaction_request_bytes: Option<u32>,
61    /// Maximum decoded Solana message or transaction size
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub max_transaction_bytes: Option<u32>,
64    /// Maximum concurrent transaction operations for this subject
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub max_transaction_concurrency: Option<u32>,
67    /// Maximum WebSocket connection attempts per minute
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub max_connection_attempts_per_minute: Option<u32>,
70    /// Maximum subscription creations per minute
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub max_subscription_creates_per_minute: Option<u32>,
73}
74
75/// Session token claims
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct SessionClaims {
78    /// Issuer - who issued this token
79    pub iss: String,
80    /// Subject - who this token is for
81    pub sub: String,
82    /// Audience - intended recipient (e.g., deployment ID)
83    pub aud: String,
84    /// Issued at (Unix timestamp)
85    pub iat: u64,
86    /// Not valid before (Unix timestamp)
87    pub nbf: u64,
88    /// Expiration time (Unix timestamp)
89    pub exp: u64,
90    /// JWT ID - unique identifier for this token
91    pub jti: String,
92    /// Scope - permissions granted
93    pub scope: String,
94    /// Metering key - for usage attribution
95    pub metering_key: String,
96    /// Deployment ID (optional)
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub deployment_id: Option<String>,
99    /// Typed resource target (optional for legacy deployment tokens)
100    #[serde(
101        default,
102        rename = "targetKind",
103        skip_serializing_if = "Option::is_none"
104    )]
105    pub target_kind: Option<TargetKind>,
106    /// Public target identifier (optional for legacy deployment tokens)
107    #[serde(default, rename = "targetId", skip_serializing_if = "Option::is_none")]
108    pub target_id: Option<String>,
109    /// Program allowed by this token
110    #[serde(default, rename = "programId", skip_serializing_if = "Option::is_none")]
111    pub program_id: Option<String>,
112    /// Exact immutable program release allowed by this token
113    #[serde(
114        default,
115        rename = "programReleaseHash",
116        skip_serializing_if = "Option::is_none"
117    )]
118    pub program_release_hash: Option<String>,
119    /// Origin binding (optional, defense-in-depth)
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub origin: Option<String>,
122    /// Client IP binding (optional, for high-security scenarios)
123    #[serde(skip_serializing_if = "Option::is_none", rename = "client_ip")]
124    pub client_ip: Option<String>,
125    /// Resource limits
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub limits: Option<Limits>,
128    /// Plan identifier (optional)
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub plan: Option<String>,
131    /// Key class (secret vs publishable)
132    #[serde(rename = "key_class")]
133    pub key_class: KeyClass,
134    /// Authenticated user/service or anonymous actor identity (v2 policy claim)
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub actor_key: Option<String>,
137    /// Billing aggregate identity, e.g. `account:42` (v2 policy claim)
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub account_key: Option<String>,
140    /// Browser/process fairness identity (v2 policy claim)
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub consumer_key: Option<String>,
143    /// Monotonic account policy version (v2 policy claim)
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub policy_version: Option<u32>,
146    /// Aggregate account limits within a runtime/quota backend (v2 policy claim)
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub account_limits: Option<Limits>,
149}
150
151/// Maximum accepted byte length for a v2 policy identity.
152pub const MAX_POLICY_IDENTITY_BYTES: usize = 512;
153
154/// Reserved plan code carried by anonymous v2 tokens.
155pub const PLAN_ANONYMOUS: &str = "anonymous";
156
157fn valid_policy_identity(value: &str) -> bool {
158    !value.is_empty()
159        && value.len() <= MAX_POLICY_IDENTITY_BYTES
160        && value.bytes().all(|byte| {
161            byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':' | b'@' | b'/')
162        })
163}
164
165pub(crate) fn resolve_policy_identity<'a>(explicit: Option<&'a str>, fallback: &'a str) -> &'a str {
166    explicit.unwrap_or(fallback)
167}
168
169/// Failure to validate the v2 policy identity claim set.
170#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
171pub enum PolicyClaimsError {
172    #[error(
173        "invalid {0} identity: must be 1-{MAX_POLICY_IDENTITY_BYTES} bytes of [A-Za-z0-9._:@/-]"
174    )]
175    InvalidIdentity(&'static str),
176    #[error("incomplete policy claims: {0}")]
177    IncompleteClaims(&'static str),
178}
179
180impl SessionClaims {
181    /// Create a new session claims builder
182    pub fn builder(
183        iss: impl Into<String>,
184        sub: impl Into<String>,
185        aud: impl Into<String>,
186    ) -> SessionClaimsBuilder {
187        SessionClaimsBuilder::new(iss, sub, aud)
188    }
189
190    /// Create claims for one exact program-read binding, program, and release.
191    pub fn program_read_builder(
192        iss: impl Into<String>,
193        sub: impl Into<String>,
194        target_id: impl Into<String>,
195        program_id: impl Into<String>,
196        program_release_hash: impl Into<String>,
197    ) -> SessionClaimsBuilder {
198        SessionClaimsBuilder::new(iss, sub, crate::PROGRAM_READ_AUDIENCE).with_program_read_binding(
199            target_id,
200            program_id,
201            program_release_hash,
202        )
203    }
204
205    /// Create claims for one regional Solana gateway binding.
206    pub fn solana_gateway_builder(
207        iss: impl Into<String>,
208        sub: impl Into<String>,
209        target_id: impl Into<String>,
210    ) -> SessionClaimsBuilder {
211        SessionClaimsBuilder::new(iss, sub, crate::SOLANA_GATEWAY_AUDIENCE)
212            .with_solana_gateway_binding(target_id)
213    }
214
215    /// Validate the v2 policy identity claim set.
216    ///
217    /// Old tokens with none of the new fields remain valid. A token that
218    /// supplies only a subset that would create ambiguous attribution is
219    /// rejected:
220    /// - account-scoped tokens require `actor_key`, `account_key`,
221    ///   `consumer_key`, `policy_version`, and `account_limits` together;
222    /// - anonymous tokens require `actor_key`, `consumer_key`, and
223    ///   `policy_version` with `plan = "anonymous"` and no account fields.
224    pub fn validate_policy_claims(&self) -> Result<(), PolicyClaimsError> {
225        for (name, value) in [
226            ("actor_key", &self.actor_key),
227            ("account_key", &self.account_key),
228            ("consumer_key", &self.consumer_key),
229        ] {
230            if let Some(value) = value {
231                if !valid_policy_identity(value) {
232                    return Err(PolicyClaimsError::InvalidIdentity(name));
233                }
234            }
235        }
236
237        let has_any = self.actor_key.is_some()
238            || self.account_key.is_some()
239            || self.consumer_key.is_some()
240            || self.policy_version.is_some()
241            || self.account_limits.is_some();
242        if !has_any {
243            return Ok(());
244        }
245
246        let has_core = self.actor_key.is_some()
247            && self.consumer_key.is_some()
248            && self.policy_version.is_some();
249        if self.account_key.is_some() || self.account_limits.is_some() {
250            if !has_core || self.account_key.is_none() || self.account_limits.is_none() {
251                return Err(PolicyClaimsError::IncompleteClaims(
252                    "account-scoped tokens require actor_key, account_key, consumer_key, \
253                     policy_version, and account_limits together",
254                ));
255            }
256            return Ok(());
257        }
258
259        if !has_core {
260            return Err(PolicyClaimsError::IncompleteClaims(
261                "policy claims require actor_key, consumer_key, and policy_version together",
262            ));
263        }
264        if self.plan.as_deref() != Some(PLAN_ANONYMOUS) {
265            return Err(PolicyClaimsError::IncompleteClaims(
266                "tokens without account_key must declare the anonymous plan",
267            ));
268        }
269        Ok(())
270    }
271
272    /// Check if the token is expired
273    pub fn is_expired(&self, now: u64) -> bool {
274        self.exp <= now
275    }
276
277    /// Check if the token is valid (not before issued)
278    pub fn is_valid(&self, now: u64) -> bool {
279        self.nbf <= now && self.iat <= now
280    }
281}
282
283/// Builder for SessionClaims
284pub struct SessionClaimsBuilder {
285    iss: String,
286    sub: String,
287    aud: String,
288    iat: u64,
289    nbf: u64,
290    exp: u64,
291    jti: String,
292    scope: String,
293    metering_key: String,
294    deployment_id: Option<String>,
295    target_kind: Option<TargetKind>,
296    target_id: Option<String>,
297    program_id: Option<String>,
298    program_release_hash: Option<String>,
299    origin: Option<String>,
300    client_ip: Option<String>,
301    limits: Option<Limits>,
302    plan: Option<String>,
303    key_class: KeyClass,
304    actor_key: Option<String>,
305    account_key: Option<String>,
306    consumer_key: Option<String>,
307    policy_version: Option<u32>,
308    account_limits: Option<Limits>,
309}
310
311impl SessionClaimsBuilder {
312    fn new(iss: impl Into<String>, sub: impl Into<String>, aud: impl Into<String>) -> Self {
313        use std::time::{SystemTime, UNIX_EPOCH};
314        let now = SystemTime::now()
315            .duration_since(UNIX_EPOCH)
316            .expect("time should not be before epoch")
317            .as_secs();
318
319        Self {
320            iss: iss.into(),
321            sub: sub.into(),
322            aud: aud.into(),
323            iat: now,
324            nbf: now,
325            exp: now + crate::DEFAULT_SESSION_TTL_SECONDS,
326            jti: uuid::Uuid::new_v4().to_string(),
327            scope: crate::SCOPE_READ.to_string(),
328            metering_key: String::new(),
329            deployment_id: None,
330            target_kind: None,
331            target_id: None,
332            program_id: None,
333            program_release_hash: None,
334            origin: None,
335            client_ip: None,
336            limits: None,
337            plan: None,
338            key_class: KeyClass::Publishable,
339            actor_key: None,
340            account_key: None,
341            consumer_key: None,
342            policy_version: None,
343            account_limits: None,
344        }
345    }
346
347    pub fn with_ttl(mut self, ttl_seconds: u64) -> Self {
348        self.exp = self.iat + ttl_seconds;
349        self
350    }
351
352    pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
353        self.scope = scope.into();
354        self
355    }
356
357    pub fn with_metering_key(mut self, key: impl Into<String>) -> Self {
358        self.metering_key = key.into();
359        self
360    }
361
362    pub fn with_deployment_id(mut self, id: impl Into<String>) -> Self {
363        self.deployment_id = Some(id.into());
364        self
365    }
366
367    /// Bind claims to a typed target.
368    pub fn with_target(mut self, kind: TargetKind, id: impl Into<String>) -> Self {
369        self.target_kind = Some(kind);
370        self.target_id = Some(id.into());
371        self
372    }
373
374    /// Allow reads for a program.
375    pub fn with_program_id(mut self, program_id: impl Into<String>) -> Self {
376        self.program_id = Some(program_id.into());
377        self
378    }
379
380    /// Restrict reads to one immutable program release.
381    pub fn with_program_release_hash(mut self, hash: impl Into<String>) -> Self {
382        self.program_release_hash = Some(hash.into());
383        self
384    }
385
386    /// Configure an exact program-read target and its immutable release.
387    pub fn with_program_read_binding(
388        mut self,
389        target_id: impl Into<String>,
390        program_id: impl Into<String>,
391        program_release_hash: impl Into<String>,
392    ) -> Self {
393        self.aud = crate::PROGRAM_READ_AUDIENCE.to_string();
394        self.scope = crate::SCOPE_READ.to_string();
395        self.target_kind = Some(TargetKind::ProgramReadBinding);
396        self.target_id = Some(target_id.into());
397        self.program_id = Some(program_id.into());
398        self.program_release_hash = Some(program_release_hash.into());
399        self
400    }
401
402    /// Configure a typed regional Solana gateway target.
403    pub fn with_solana_gateway_binding(mut self, target_id: impl Into<String>) -> Self {
404        self.aud = crate::SOLANA_GATEWAY_AUDIENCE.to_string();
405        self.scope = crate::SCOPE_READ.to_string();
406        self.target_kind = Some(TargetKind::SolanaGatewayBinding);
407        self.target_id = Some(target_id.into());
408        self
409    }
410
411    pub fn with_origin(mut self, origin: impl Into<String>) -> Self {
412        self.origin = Some(origin.into());
413        self
414    }
415
416    pub fn with_client_ip(mut self, client_ip: impl Into<String>) -> Self {
417        self.client_ip = Some(client_ip.into());
418        self
419    }
420
421    pub fn with_limits(mut self, limits: Limits) -> Self {
422        self.limits = Some(limits);
423        self
424    }
425
426    pub fn with_plan(mut self, plan: impl Into<String>) -> Self {
427        self.plan = Some(plan.into());
428        self
429    }
430
431    pub fn with_key_class(mut self, key_class: KeyClass) -> Self {
432        self.key_class = key_class;
433        self
434    }
435
436    pub fn with_jti(mut self, jti: impl Into<String>) -> Self {
437        self.jti = jti.into();
438        self
439    }
440
441    /// Set the authenticated actor identity (v2 policy claim).
442    pub fn with_actor_key(mut self, actor_key: impl Into<String>) -> Self {
443        self.actor_key = Some(actor_key.into());
444        self
445    }
446
447    /// Set the billing account identity (v2 policy claim).
448    pub fn with_account_key(mut self, account_key: impl Into<String>) -> Self {
449        self.account_key = Some(account_key.into());
450        self
451    }
452
453    /// Set the consumer fairness identity (v2 policy claim).
454    pub fn with_consumer_key(mut self, consumer_key: impl Into<String>) -> Self {
455        self.consumer_key = Some(consumer_key.into());
456        self
457    }
458
459    /// Set the monotonic account policy version (v2 policy claim).
460    pub fn with_policy_version(mut self, policy_version: u32) -> Self {
461        self.policy_version = Some(policy_version);
462        self
463    }
464
465    /// Set the aggregate account limits (v2 policy claim).
466    pub fn with_account_limits(mut self, account_limits: Limits) -> Self {
467        self.account_limits = Some(account_limits);
468        self
469    }
470
471    pub fn build(self) -> SessionClaims {
472        SessionClaims {
473            iss: self.iss,
474            sub: self.sub,
475            aud: self.aud,
476            iat: self.iat,
477            nbf: self.nbf,
478            exp: self.exp,
479            jti: self.jti,
480            scope: self.scope,
481            metering_key: self.metering_key,
482            deployment_id: self.deployment_id,
483            target_kind: self.target_kind,
484            target_id: self.target_id,
485            program_id: self.program_id,
486            program_release_hash: self.program_release_hash,
487            origin: self.origin,
488            client_ip: self.client_ip,
489            limits: self.limits,
490            plan: self.plan,
491            key_class: self.key_class,
492            actor_key: self.actor_key,
493            account_key: self.account_key,
494            consumer_key: self.consumer_key,
495            policy_version: self.policy_version,
496            account_limits: self.account_limits,
497        }
498    }
499}
500
501/// Auth context extracted from a verified token
502#[derive(Debug, Clone)]
503pub struct AuthContext {
504    /// Subject identifier
505    pub subject: String,
506    /// Issuer
507    pub issuer: String,
508    /// Verified JWT audience
509    pub audience: String,
510    /// Key class (secret vs publishable)
511    pub key_class: KeyClass,
512    /// Metering key for usage attribution
513    pub metering_key: String,
514    /// Deployment ID binding
515    pub deployment_id: Option<String>,
516    /// Typed resource target
517    pub target_kind: Option<TargetKind>,
518    /// Public target identifier
519    pub target_id: Option<String>,
520    /// Program allowed by the token
521    pub program_id: Option<String>,
522    /// Exact immutable program release allowed by the token
523    pub program_release_hash: Option<String>,
524    /// Token expiration time
525    pub expires_at: u64,
526    /// Granted scope
527    pub scope: String,
528    /// Resource limits
529    pub limits: Limits,
530    /// Plan or access tier associated with the session
531    pub plan: Option<String>,
532    /// Origin binding
533    pub origin: Option<String>,
534    /// Client IP binding
535    pub client_ip: Option<String>,
536    /// JWT ID
537    pub jti: String,
538    /// Raw signed actor identity; use [`AuthContext::actor_key`] to resolve
539    pub actor_key: Option<String>,
540    /// Raw signed account identity; use [`AuthContext::account_key`] to resolve
541    pub account_key: Option<String>,
542    /// Raw signed consumer identity; use [`AuthContext::consumer_key`] to resolve
543    pub consumer_key: Option<String>,
544    /// Monotonic account policy version, absent on legacy tokens
545    pub policy_version: Option<u32>,
546    /// Aggregate account limits, defaulted when the token carries none
547    pub account_limits: Limits,
548}
549
550impl AuthContext {
551    /// Test an exact whitespace-delimited scope. Scopes never imply one another.
552    pub fn has_scope(&self, required: &str) -> bool {
553        self.scope.split_whitespace().any(|scope| scope == required)
554    }
555
556    /// Resolved actor identity: `actor_key` claim, falling back to `sub`.
557    pub fn actor_key(&self) -> &str {
558        resolve_policy_identity(self.actor_key.as_deref(), &self.subject)
559    }
560
561    /// Resolved consumer identity: `consumer_key` claim, falling back to `sub`.
562    pub fn consumer_key(&self) -> &str {
563        resolve_policy_identity(self.consumer_key.as_deref(), &self.subject)
564    }
565
566    /// Resolved account identity: `account_key` claim, falling back to
567    /// `metering_key`.
568    pub fn account_key(&self) -> &str {
569        resolve_policy_identity(self.account_key.as_deref(), &self.metering_key)
570    }
571
572    /// True when the token predates the v2 policy contract (all new
573    /// identity/version fields are absent).
574    pub fn is_legacy_policy(&self) -> bool {
575        self.actor_key.is_none()
576            && self.account_key.is_none()
577            && self.consumer_key.is_none()
578            && self.policy_version.is_none()
579    }
580
581    /// Create AuthContext from verified claims
582    pub fn from_claims(claims: SessionClaims) -> Self {
583        Self {
584            subject: claims.sub,
585            issuer: claims.iss,
586            audience: claims.aud,
587            key_class: claims.key_class,
588            metering_key: claims.metering_key,
589            deployment_id: claims.deployment_id,
590            target_kind: claims.target_kind,
591            target_id: claims.target_id,
592            program_id: claims.program_id,
593            program_release_hash: claims.program_release_hash,
594            expires_at: claims.exp,
595            scope: claims.scope,
596            limits: claims.limits.unwrap_or_default(),
597            plan: claims.plan,
598            origin: claims.origin,
599            client_ip: claims.client_ip,
600            jti: claims.jti,
601            actor_key: claims.actor_key,
602            account_key: claims.account_key,
603            consumer_key: claims.consumer_key,
604            policy_version: claims.policy_version,
605            account_limits: claims.account_limits.unwrap_or_default(),
606        }
607    }
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613
614    #[test]
615    fn scopes_are_exact_and_independent() {
616        let context = AuthContext::from_claims(
617            SessionClaims::builder("issuer", "subject", "audience")
618                .with_scope("read transaction:inspect transaction:send-extra")
619                .build(),
620        );
621
622        assert!(context.has_scope("read"));
623        assert!(context.has_scope("transaction:inspect"));
624        assert!(!context.has_scope("transaction:send"));
625        assert!(!context.has_scope("transaction"));
626    }
627
628    #[test]
629    fn old_limits_claims_remain_deserializable() {
630        let limits: Limits = serde_json::from_value(serde_json::json!({
631            "max_connections": 2
632        }))
633        .unwrap();
634
635        assert_eq!(limits.max_connections, Some(2));
636        assert_eq!(limits.max_transaction_bytes, None);
637    }
638
639    #[test]
640    fn transaction_limits_round_trip_additively() {
641        let limits = Limits {
642            max_transaction_inspect_requests_per_minute: Some(120),
643            max_transaction_send_requests_per_minute: Some(12),
644            max_transaction_status_requests_per_minute: Some(240),
645            max_transaction_request_bytes: Some(4096),
646            max_transaction_bytes: Some(1232),
647            max_transaction_concurrency: Some(4),
648            ..Limits::default()
649        };
650        let value = serde_json::to_value(&limits).unwrap();
651        let decoded: Limits = serde_json::from_value(value).unwrap();
652
653        assert_eq!(decoded.max_transaction_bytes, Some(1232));
654        assert_eq!(decoded.max_transaction_concurrency, Some(4));
655    }
656
657    #[test]
658    fn program_read_claims_use_camel_case_fields() {
659        let claims = SessionClaims::program_read_builder(
660            "issuer",
661            "subject",
662            "binding-1",
663            "program-1",
664            "release-1",
665        )
666        .build();
667        let value = serde_json::to_value(claims).unwrap();
668
669        assert_eq!(value["aud"], crate::PROGRAM_READ_AUDIENCE);
670        assert_eq!(value["targetKind"], "program-read-binding");
671        assert_eq!(value["targetId"], "binding-1");
672        assert_eq!(value["programId"], "program-1");
673        assert_eq!(value["programReleaseHash"], "release-1");
674        assert!(value.get("target_kind").is_none());
675    }
676
677    #[test]
678    fn gateway_claims_use_stable_audience_target_and_default_scope() {
679        let claims =
680            SessionClaims::solana_gateway_builder("issuer", "subject", "gateway-us-east-1").build();
681        let value = serde_json::to_value(claims).unwrap();
682
683        assert_eq!(value["aud"], crate::SOLANA_GATEWAY_AUDIENCE);
684        assert_eq!(value["targetKind"], "solana-gateway-binding");
685        assert_eq!(value["targetId"], "gateway-us-east-1");
686        assert_eq!(value["scope"], crate::SCOPE_READ);
687    }
688
689    fn account_limits() -> Limits {
690        Limits {
691            max_connections: Some(50),
692            max_messages_per_minute: Some(50_000),
693            max_connection_attempts_per_minute: Some(600),
694            max_subscription_creates_per_minute: Some(1_200),
695            ..Limits::default()
696        }
697    }
698
699    fn v2_builder() -> SessionClaimsBuilder {
700        SessionClaims::builder("issuer", "user:1", "deployment-1")
701            .with_metering_key("account:42")
702            .with_plan("pro")
703            .with_actor_key("user:1")
704            .with_account_key("account:42")
705            .with_consumer_key("consumer:abc123")
706            .with_policy_version(7)
707            .with_account_limits(account_limits())
708    }
709
710    #[test]
711    fn golden_old_token_json_still_deserializes_and_resolves() {
712        // Wire shape emitted before the v2 policy contract existed.
713        let claims: SessionClaims = serde_json::from_value(serde_json::json!({
714            "iss": "issuer",
715            "sub": "user:1",
716            "aud": "deployment-1",
717            "iat": 1, "nbf": 1, "exp": 2, "jti": "jti-1",
718            "scope": "read",
719            "metering_key": "api_key:42",
720            "limits": { "max_connections": 2 },
721            "plan": "starter",
722            "key_class": "publishable"
723        }))
724        .unwrap();
725
726        claims.validate_policy_claims().unwrap();
727        let context = AuthContext::from_claims(claims);
728
729        assert!(context.is_legacy_policy());
730        assert_eq!(context.actor_key(), "user:1");
731        assert_eq!(context.consumer_key(), "user:1");
732        assert_eq!(context.account_key(), "api_key:42");
733        assert_eq!(context.account_limits, Limits::default());
734        assert_eq!(context.limits.max_connections, Some(2));
735    }
736
737    #[test]
738    fn tokens_built_without_new_methods_serialize_to_the_old_shape() {
739        let claims = SessionClaims::builder("issuer", "user:1", "deployment-1")
740            .with_metering_key("api_key:42")
741            .build();
742        let value = serde_json::to_value(claims).unwrap();
743
744        for absent in [
745            "actor_key",
746            "account_key",
747            "consumer_key",
748            "policy_version",
749            "account_limits",
750        ] {
751            assert!(value.get(absent).is_none(), "{absent} must be absent");
752        }
753    }
754
755    #[test]
756    fn v2_claims_round_trip_with_exact_snake_case_wire_keys() {
757        let claims = v2_builder().build();
758        claims.validate_policy_claims().unwrap();
759        let value = serde_json::to_value(&claims).unwrap();
760
761        assert_eq!(value["actor_key"], "user:1");
762        assert_eq!(value["account_key"], "account:42");
763        assert_eq!(value["consumer_key"], "consumer:abc123");
764        assert_eq!(value["policy_version"], 7);
765        assert_eq!(value["account_limits"]["max_connections"], 50);
766        assert_eq!(
767            value["account_limits"]["max_connection_attempts_per_minute"],
768            600
769        );
770        assert_eq!(
771            value["account_limits"]["max_subscription_creates_per_minute"],
772            1200
773        );
774
775        let decoded: SessionClaims = serde_json::from_value(value).unwrap();
776        assert_eq!(decoded.actor_key.as_deref(), Some("user:1"));
777        assert_eq!(decoded.policy_version, Some(7));
778        assert_eq!(decoded.account_limits, Some(account_limits()));
779
780        let context = AuthContext::from_claims(decoded);
781        assert!(!context.is_legacy_policy());
782        assert_eq!(context.actor_key(), "user:1");
783        assert_eq!(context.consumer_key(), "consumer:abc123");
784        assert_eq!(context.account_key(), "account:42");
785        assert_eq!(context.policy_version, Some(7));
786        assert_eq!(context.account_limits, account_limits());
787    }
788
789    #[test]
790    fn anonymous_v2_claims_require_the_anonymous_plan_and_no_account() {
791        let anonymous = SessionClaims::builder("issuer", "anon:ip-1", "deployment-1")
792            .with_metering_key("anon:ip-1")
793            .with_plan(PLAN_ANONYMOUS)
794            .with_actor_key("anon:ip-1")
795            .with_consumer_key("consumer:abc123")
796            .with_policy_version(3)
797            .build();
798        anonymous.validate_policy_claims().unwrap();
799
800        let wrong_plan = SessionClaims::builder("issuer", "anon:ip-1", "deployment-1")
801            .with_plan("pro")
802            .with_actor_key("anon:ip-1")
803            .with_consumer_key("consumer:abc123")
804            .with_policy_version(3)
805            .build();
806        assert!(matches!(
807            wrong_plan.validate_policy_claims(),
808            Err(PolicyClaimsError::IncompleteClaims(_))
809        ));
810    }
811
812    #[test]
813    fn partial_v2_identity_subsets_are_rejected() {
814        // account_key without the rest of the authenticated tuple
815        let missing_consumer = SessionClaims::builder("issuer", "user:1", "deployment-1")
816            .with_actor_key("user:1")
817            .with_account_key("account:42")
818            .with_policy_version(1)
819            .with_account_limits(Limits::default())
820            .build();
821        assert!(matches!(
822            missing_consumer.validate_policy_claims(),
823            Err(PolicyClaimsError::IncompleteClaims(_))
824        ));
825
826        // account_limits alone
827        let limits_only = SessionClaims::builder("issuer", "user:1", "deployment-1")
828            .with_account_limits(Limits::default())
829            .build();
830        assert!(limits_only.validate_policy_claims().is_err());
831
832        // policy_version alone
833        let version_only = SessionClaims::builder("issuer", "user:1", "deployment-1")
834            .with_policy_version(1)
835            .build();
836        assert!(version_only.validate_policy_claims().is_err());
837
838        // account tuple missing account_limits
839        let missing_limits = SessionClaims::builder("issuer", "user:1", "deployment-1")
840            .with_actor_key("user:1")
841            .with_account_key("account:42")
842            .with_consumer_key("consumer:abc123")
843            .with_policy_version(1)
844            .build();
845        assert!(missing_limits.validate_policy_claims().is_err());
846    }
847
848    #[test]
849    fn malformed_or_oversized_identities_are_rejected() {
850        let empty = v2_builder().with_consumer_key("").build();
851        assert_eq!(
852            empty.validate_policy_claims(),
853            Err(PolicyClaimsError::InvalidIdentity("consumer_key"))
854        );
855
856        let oversized = v2_builder()
857            .with_account_key("a".repeat(MAX_POLICY_IDENTITY_BYTES + 1))
858            .build();
859        assert_eq!(
860            oversized.validate_policy_claims(),
861            Err(PolicyClaimsError::InvalidIdentity("account_key"))
862        );
863
864        let bad_charset = v2_builder().with_actor_key("user 1\n").build();
865        assert_eq!(
866            bad_charset.validate_policy_claims(),
867            Err(PolicyClaimsError::InvalidIdentity("actor_key"))
868        );
869
870        let boundary = v2_builder()
871            .with_account_key("a".repeat(MAX_POLICY_IDENTITY_BYTES))
872            .build();
873        assert!(boundary.validate_policy_claims().is_ok());
874    }
875
876    #[test]
877    fn legacy_deployment_claims_remain_untyped() {
878        let claims = SessionClaims::builder("issuer", "subject", "deployment-1")
879            .with_deployment_id("deployment-1")
880            .build();
881        let value = serde_json::to_value(&claims).unwrap();
882        let decoded: SessionClaims = serde_json::from_value(value.clone()).unwrap();
883
884        assert_eq!(decoded.deployment_id.as_deref(), Some("deployment-1"));
885        assert_eq!(decoded.target_kind, None);
886        assert!(value.get("targetKind").is_none());
887    }
888}