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    /// Row scopes this session may read (row scoping). A row of a scoped
150    /// entity is delivered only when its scope is empty or listed here. Set
151    /// by the issuer from data it trusts, never from a client request.
152    /// Absent on tokens that predate it, which then see only public rows of
153    /// scoped entities.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub row_scopes: Option<Vec<String>>,
156}
157
158/// Maximum accepted byte length for a v2 policy identity.
159pub const MAX_POLICY_IDENTITY_BYTES: usize = 512;
160
161/// Reserved plan code carried by anonymous v2 tokens.
162pub 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/// Failure to validate the v2 policy identity claim set.
177#[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    /// Create a new session claims builder
189    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    /// Create claims for one exact program-read binding, program, and release.
198    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    /// Create claims for one regional Solana gateway binding.
213    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    /// Validate the v2 policy identity claim set.
223    ///
224    /// Old tokens with none of the new fields remain valid. A token that
225    /// supplies only a subset that would create ambiguous attribution is
226    /// rejected:
227    /// - account-scoped tokens require `actor_key`, `account_key`,
228    ///   `consumer_key`, `policy_version`, and `account_limits` together;
229    /// - anonymous tokens require `actor_key`, `consumer_key`, and
230    ///   `policy_version` with `plan = "anonymous"` and no account fields.
231    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    /// Check if the token is expired
280    pub fn is_expired(&self, now: u64) -> bool {
281        self.exp <= now
282    }
283
284    /// Check if the token is valid (not before issued)
285    pub fn is_valid(&self, now: u64) -> bool {
286        self.nbf <= now && self.iat <= now
287    }
288}
289
290/// Builder for SessionClaims
291pub 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    /// Bind claims to a typed target.
377    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    /// Allow reads for a program.
384    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    /// Restrict reads to one immutable program release.
390    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    /// Configure an exact program-read target and its immutable release.
396    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    /// Configure a typed regional Solana gateway target.
412    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    /// Set the authenticated actor identity (v2 policy claim).
451    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    /// Set the billing account identity (v2 policy claim).
457    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    /// Set the consumer fairness identity (v2 policy claim).
463    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    /// Set the monotonic account policy version (v2 policy claim).
469    pub fn with_policy_version(mut self, policy_version: u32) -> Self {
470        self.policy_version = Some(policy_version);
471        self
472    }
473
474    /// Set the aggregate account limits (v2 policy claim).
475    pub fn with_account_limits(mut self, account_limits: Limits) -> Self {
476        self.account_limits = Some(account_limits);
477        self
478    }
479
480    /// Set the row scopes this session may read (row scoping).
481    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/// Auth context extracted from a verified token
521#[derive(Debug, Clone)]
522pub struct AuthContext {
523    /// Subject identifier
524    pub subject: String,
525    /// Issuer
526    pub issuer: String,
527    /// Verified JWT audience
528    pub audience: String,
529    /// Key class (secret vs publishable)
530    pub key_class: KeyClass,
531    /// Metering key for usage attribution
532    pub metering_key: String,
533    /// Deployment ID binding
534    pub deployment_id: Option<String>,
535    /// Typed resource target
536    pub target_kind: Option<TargetKind>,
537    /// Public target identifier
538    pub target_id: Option<String>,
539    /// Program allowed by the token
540    pub program_id: Option<String>,
541    /// Exact immutable program release allowed by the token
542    pub program_release_hash: Option<String>,
543    /// Token expiration time
544    pub expires_at: u64,
545    /// Granted scope
546    pub scope: String,
547    /// Resource limits
548    pub limits: Limits,
549    /// Plan or access tier associated with the session
550    pub plan: Option<String>,
551    /// Origin binding
552    pub origin: Option<String>,
553    /// Client IP binding
554    pub client_ip: Option<String>,
555    /// JWT ID
556    pub jti: String,
557    /// Raw signed actor identity; use [`AuthContext::actor_key`] to resolve
558    pub actor_key: Option<String>,
559    /// Raw signed account identity; use [`AuthContext::account_key`] to resolve
560    pub account_key: Option<String>,
561    /// Raw signed consumer identity; use [`AuthContext::consumer_key`] to resolve
562    pub consumer_key: Option<String>,
563    /// Monotonic account policy version, absent on legacy tokens
564    pub policy_version: Option<u32>,
565    /// Aggregate account limits, defaulted when the token carries none
566    pub account_limits: Limits,
567    /// Row scopes this session may read; `None` when the token carries none
568    /// (see [`SessionClaims::row_scopes`])
569    pub row_scopes: Option<Vec<String>>,
570}
571
572impl AuthContext {
573    /// Test an exact whitespace-delimited scope. Scopes never imply one another.
574    pub fn has_scope(&self, required: &str) -> bool {
575        self.scope.split_whitespace().any(|scope| scope == required)
576    }
577
578    /// Resolved actor identity: `actor_key` claim, falling back to `sub`.
579    pub fn actor_key(&self) -> &str {
580        resolve_policy_identity(self.actor_key.as_deref(), &self.subject)
581    }
582
583    /// Resolved consumer identity: `consumer_key` claim, falling back to `sub`.
584    pub fn consumer_key(&self) -> &str {
585        resolve_policy_identity(self.consumer_key.as_deref(), &self.subject)
586    }
587
588    /// Resolved account identity: `account_key` claim, falling back to
589    /// `metering_key`.
590    pub fn account_key(&self) -> &str {
591        resolve_policy_identity(self.account_key.as_deref(), &self.metering_key)
592    }
593
594    /// True when the token predates the v2 policy contract (all new
595    /// identity/version fields are absent).
596    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    /// Create AuthContext from verified claims
604    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        // `scope` keeps carrying permission words; row scopes are separate.
665        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        // Wire shape emitted before the v2 policy contract existed.
756        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        // account_key without the rest of the authenticated tuple
858        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        // account_limits alone
870        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        // policy_version alone
876        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        // account tuple missing account_limits
882        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}