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, 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}
68
69/// Session token claims
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct SessionClaims {
72    /// Issuer - who issued this token
73    pub iss: String,
74    /// Subject - who this token is for
75    pub sub: String,
76    /// Audience - intended recipient (e.g., deployment ID)
77    pub aud: String,
78    /// Issued at (Unix timestamp)
79    pub iat: u64,
80    /// Not valid before (Unix timestamp)
81    pub nbf: u64,
82    /// Expiration time (Unix timestamp)
83    pub exp: u64,
84    /// JWT ID - unique identifier for this token
85    pub jti: String,
86    /// Scope - permissions granted
87    pub scope: String,
88    /// Metering key - for usage attribution
89    pub metering_key: String,
90    /// Deployment ID (optional)
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub deployment_id: Option<String>,
93    /// Typed resource target (optional for legacy deployment tokens)
94    #[serde(
95        default,
96        rename = "targetKind",
97        skip_serializing_if = "Option::is_none"
98    )]
99    pub target_kind: Option<TargetKind>,
100    /// Public target identifier (optional for legacy deployment tokens)
101    #[serde(default, rename = "targetId", skip_serializing_if = "Option::is_none")]
102    pub target_id: Option<String>,
103    /// Program allowed by this token
104    #[serde(default, rename = "programId", skip_serializing_if = "Option::is_none")]
105    pub program_id: Option<String>,
106    /// Exact immutable program release allowed by this token
107    #[serde(
108        default,
109        rename = "programReleaseHash",
110        skip_serializing_if = "Option::is_none"
111    )]
112    pub program_release_hash: Option<String>,
113    /// Origin binding (optional, defense-in-depth)
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub origin: Option<String>,
116    /// Client IP binding (optional, for high-security scenarios)
117    #[serde(skip_serializing_if = "Option::is_none", rename = "client_ip")]
118    pub client_ip: Option<String>,
119    /// Resource limits
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub limits: Option<Limits>,
122    /// Plan identifier (optional)
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub plan: Option<String>,
125    /// Key class (secret vs publishable)
126    #[serde(rename = "key_class")]
127    pub key_class: KeyClass,
128}
129
130impl SessionClaims {
131    /// Create a new session claims builder
132    pub fn builder(
133        iss: impl Into<String>,
134        sub: impl Into<String>,
135        aud: impl Into<String>,
136    ) -> SessionClaimsBuilder {
137        SessionClaimsBuilder::new(iss, sub, aud)
138    }
139
140    /// Create claims for one exact program-read binding, program, and release.
141    pub fn program_read_builder(
142        iss: impl Into<String>,
143        sub: impl Into<String>,
144        target_id: impl Into<String>,
145        program_id: impl Into<String>,
146        program_release_hash: impl Into<String>,
147    ) -> SessionClaimsBuilder {
148        SessionClaimsBuilder::new(iss, sub, crate::PROGRAM_READ_AUDIENCE).with_program_read_binding(
149            target_id,
150            program_id,
151            program_release_hash,
152        )
153    }
154
155    /// Create claims for one regional Solana gateway binding.
156    pub fn solana_gateway_builder(
157        iss: impl Into<String>,
158        sub: impl Into<String>,
159        target_id: impl Into<String>,
160    ) -> SessionClaimsBuilder {
161        SessionClaimsBuilder::new(iss, sub, crate::SOLANA_GATEWAY_AUDIENCE)
162            .with_solana_gateway_binding(target_id)
163    }
164
165    /// Check if the token is expired
166    pub fn is_expired(&self, now: u64) -> bool {
167        self.exp <= now
168    }
169
170    /// Check if the token is valid (not before issued)
171    pub fn is_valid(&self, now: u64) -> bool {
172        self.nbf <= now && self.iat <= now
173    }
174}
175
176/// Builder for SessionClaims
177pub struct SessionClaimsBuilder {
178    iss: String,
179    sub: String,
180    aud: String,
181    iat: u64,
182    nbf: u64,
183    exp: u64,
184    jti: String,
185    scope: String,
186    metering_key: String,
187    deployment_id: Option<String>,
188    target_kind: Option<TargetKind>,
189    target_id: Option<String>,
190    program_id: Option<String>,
191    program_release_hash: Option<String>,
192    origin: Option<String>,
193    client_ip: Option<String>,
194    limits: Option<Limits>,
195    plan: Option<String>,
196    key_class: KeyClass,
197}
198
199impl SessionClaimsBuilder {
200    fn new(iss: impl Into<String>, sub: impl Into<String>, aud: impl Into<String>) -> Self {
201        use std::time::{SystemTime, UNIX_EPOCH};
202        let now = SystemTime::now()
203            .duration_since(UNIX_EPOCH)
204            .expect("time should not be before epoch")
205            .as_secs();
206
207        Self {
208            iss: iss.into(),
209            sub: sub.into(),
210            aud: aud.into(),
211            iat: now,
212            nbf: now,
213            exp: now + crate::DEFAULT_SESSION_TTL_SECONDS,
214            jti: uuid::Uuid::new_v4().to_string(),
215            scope: crate::SCOPE_READ.to_string(),
216            metering_key: String::new(),
217            deployment_id: None,
218            target_kind: None,
219            target_id: None,
220            program_id: None,
221            program_release_hash: None,
222            origin: None,
223            client_ip: None,
224            limits: None,
225            plan: None,
226            key_class: KeyClass::Publishable,
227        }
228    }
229
230    pub fn with_ttl(mut self, ttl_seconds: u64) -> Self {
231        self.exp = self.iat + ttl_seconds;
232        self
233    }
234
235    pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
236        self.scope = scope.into();
237        self
238    }
239
240    pub fn with_metering_key(mut self, key: impl Into<String>) -> Self {
241        self.metering_key = key.into();
242        self
243    }
244
245    pub fn with_deployment_id(mut self, id: impl Into<String>) -> Self {
246        self.deployment_id = Some(id.into());
247        self
248    }
249
250    /// Bind claims to a typed target.
251    pub fn with_target(mut self, kind: TargetKind, id: impl Into<String>) -> Self {
252        self.target_kind = Some(kind);
253        self.target_id = Some(id.into());
254        self
255    }
256
257    /// Allow reads for a program.
258    pub fn with_program_id(mut self, program_id: impl Into<String>) -> Self {
259        self.program_id = Some(program_id.into());
260        self
261    }
262
263    /// Restrict reads to one immutable program release.
264    pub fn with_program_release_hash(mut self, hash: impl Into<String>) -> Self {
265        self.program_release_hash = Some(hash.into());
266        self
267    }
268
269    /// Configure an exact program-read target and its immutable release.
270    pub fn with_program_read_binding(
271        mut self,
272        target_id: impl Into<String>,
273        program_id: impl Into<String>,
274        program_release_hash: impl Into<String>,
275    ) -> Self {
276        self.aud = crate::PROGRAM_READ_AUDIENCE.to_string();
277        self.scope = crate::SCOPE_READ.to_string();
278        self.target_kind = Some(TargetKind::ProgramReadBinding);
279        self.target_id = Some(target_id.into());
280        self.program_id = Some(program_id.into());
281        self.program_release_hash = Some(program_release_hash.into());
282        self
283    }
284
285    /// Configure a typed regional Solana gateway target.
286    pub fn with_solana_gateway_binding(mut self, target_id: impl Into<String>) -> Self {
287        self.aud = crate::SOLANA_GATEWAY_AUDIENCE.to_string();
288        self.scope = crate::SCOPE_READ.to_string();
289        self.target_kind = Some(TargetKind::SolanaGatewayBinding);
290        self.target_id = Some(target_id.into());
291        self
292    }
293
294    pub fn with_origin(mut self, origin: impl Into<String>) -> Self {
295        self.origin = Some(origin.into());
296        self
297    }
298
299    pub fn with_client_ip(mut self, client_ip: impl Into<String>) -> Self {
300        self.client_ip = Some(client_ip.into());
301        self
302    }
303
304    pub fn with_limits(mut self, limits: Limits) -> Self {
305        self.limits = Some(limits);
306        self
307    }
308
309    pub fn with_plan(mut self, plan: impl Into<String>) -> Self {
310        self.plan = Some(plan.into());
311        self
312    }
313
314    pub fn with_key_class(mut self, key_class: KeyClass) -> Self {
315        self.key_class = key_class;
316        self
317    }
318
319    pub fn with_jti(mut self, jti: impl Into<String>) -> Self {
320        self.jti = jti.into();
321        self
322    }
323
324    pub fn build(self) -> SessionClaims {
325        SessionClaims {
326            iss: self.iss,
327            sub: self.sub,
328            aud: self.aud,
329            iat: self.iat,
330            nbf: self.nbf,
331            exp: self.exp,
332            jti: self.jti,
333            scope: self.scope,
334            metering_key: self.metering_key,
335            deployment_id: self.deployment_id,
336            target_kind: self.target_kind,
337            target_id: self.target_id,
338            program_id: self.program_id,
339            program_release_hash: self.program_release_hash,
340            origin: self.origin,
341            client_ip: self.client_ip,
342            limits: self.limits,
343            plan: self.plan,
344            key_class: self.key_class,
345        }
346    }
347}
348
349/// Auth context extracted from a verified token
350#[derive(Debug, Clone)]
351pub struct AuthContext {
352    /// Subject identifier
353    pub subject: String,
354    /// Issuer
355    pub issuer: String,
356    /// Verified JWT audience
357    pub audience: String,
358    /// Key class (secret vs publishable)
359    pub key_class: KeyClass,
360    /// Metering key for usage attribution
361    pub metering_key: String,
362    /// Deployment ID binding
363    pub deployment_id: Option<String>,
364    /// Typed resource target
365    pub target_kind: Option<TargetKind>,
366    /// Public target identifier
367    pub target_id: Option<String>,
368    /// Program allowed by the token
369    pub program_id: Option<String>,
370    /// Exact immutable program release allowed by the token
371    pub program_release_hash: Option<String>,
372    /// Token expiration time
373    pub expires_at: u64,
374    /// Granted scope
375    pub scope: String,
376    /// Resource limits
377    pub limits: Limits,
378    /// Plan or access tier associated with the session
379    pub plan: Option<String>,
380    /// Origin binding
381    pub origin: Option<String>,
382    /// Client IP binding
383    pub client_ip: Option<String>,
384    /// JWT ID
385    pub jti: String,
386}
387
388impl AuthContext {
389    /// Test an exact whitespace-delimited scope. Scopes never imply one another.
390    pub fn has_scope(&self, required: &str) -> bool {
391        self.scope.split_whitespace().any(|scope| scope == required)
392    }
393
394    /// Create AuthContext from verified claims
395    pub fn from_claims(claims: SessionClaims) -> Self {
396        Self {
397            subject: claims.sub,
398            issuer: claims.iss,
399            audience: claims.aud,
400            key_class: claims.key_class,
401            metering_key: claims.metering_key,
402            deployment_id: claims.deployment_id,
403            target_kind: claims.target_kind,
404            target_id: claims.target_id,
405            program_id: claims.program_id,
406            program_release_hash: claims.program_release_hash,
407            expires_at: claims.exp,
408            scope: claims.scope,
409            limits: claims.limits.unwrap_or_default(),
410            plan: claims.plan,
411            origin: claims.origin,
412            client_ip: claims.client_ip,
413            jti: claims.jti,
414        }
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    #[test]
423    fn scopes_are_exact_and_independent() {
424        let context = AuthContext::from_claims(
425            SessionClaims::builder("issuer", "subject", "audience")
426                .with_scope("read transaction:inspect transaction:send-extra")
427                .build(),
428        );
429
430        assert!(context.has_scope("read"));
431        assert!(context.has_scope("transaction:inspect"));
432        assert!(!context.has_scope("transaction:send"));
433        assert!(!context.has_scope("transaction"));
434    }
435
436    #[test]
437    fn old_limits_claims_remain_deserializable() {
438        let limits: Limits = serde_json::from_value(serde_json::json!({
439            "max_connections": 2
440        }))
441        .unwrap();
442
443        assert_eq!(limits.max_connections, Some(2));
444        assert_eq!(limits.max_transaction_bytes, None);
445    }
446
447    #[test]
448    fn transaction_limits_round_trip_additively() {
449        let limits = Limits {
450            max_transaction_inspect_requests_per_minute: Some(120),
451            max_transaction_send_requests_per_minute: Some(12),
452            max_transaction_status_requests_per_minute: Some(240),
453            max_transaction_request_bytes: Some(4096),
454            max_transaction_bytes: Some(1232),
455            max_transaction_concurrency: Some(4),
456            ..Limits::default()
457        };
458        let value = serde_json::to_value(&limits).unwrap();
459        let decoded: Limits = serde_json::from_value(value).unwrap();
460
461        assert_eq!(decoded.max_transaction_bytes, Some(1232));
462        assert_eq!(decoded.max_transaction_concurrency, Some(4));
463    }
464
465    #[test]
466    fn program_read_claims_use_camel_case_fields() {
467        let claims = SessionClaims::program_read_builder(
468            "issuer",
469            "subject",
470            "binding-1",
471            "program-1",
472            "release-1",
473        )
474        .build();
475        let value = serde_json::to_value(claims).unwrap();
476
477        assert_eq!(value["aud"], crate::PROGRAM_READ_AUDIENCE);
478        assert_eq!(value["targetKind"], "program-read-binding");
479        assert_eq!(value["targetId"], "binding-1");
480        assert_eq!(value["programId"], "program-1");
481        assert_eq!(value["programReleaseHash"], "release-1");
482        assert!(value.get("target_kind").is_none());
483    }
484
485    #[test]
486    fn gateway_claims_use_stable_audience_target_and_default_scope() {
487        let claims =
488            SessionClaims::solana_gateway_builder("issuer", "subject", "gateway-us-east-1").build();
489        let value = serde_json::to_value(claims).unwrap();
490
491        assert_eq!(value["aud"], crate::SOLANA_GATEWAY_AUDIENCE);
492        assert_eq!(value["targetKind"], "solana-gateway-binding");
493        assert_eq!(value["targetId"], "gateway-us-east-1");
494        assert_eq!(value["scope"], crate::SCOPE_READ);
495    }
496
497    #[test]
498    fn legacy_deployment_claims_remain_untyped() {
499        let claims = SessionClaims::builder("issuer", "subject", "deployment-1")
500            .with_deployment_id("deployment-1")
501            .build();
502        let value = serde_json::to_value(&claims).unwrap();
503        let decoded: SessionClaims = serde_json::from_value(value.clone()).unwrap();
504
505        assert_eq!(decoded.deployment_id.as_deref(), Some("deployment-1"));
506        assert_eq!(decoded.target_kind, None);
507        assert!(value.get("targetKind").is_none());
508    }
509}