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/// Resource limits for a session
14#[derive(Debug, Clone, Default, Serialize, Deserialize)]
15pub struct Limits {
16    /// Maximum concurrent connections for this subject
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub max_connections: Option<u32>,
19    /// Maximum subscriptions per connection
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub max_subscriptions: Option<u32>,
22    /// Maximum snapshot rows per request
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub max_snapshot_rows: Option<u32>,
25    /// Maximum messages per minute
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub max_messages_per_minute: Option<u32>,
28    /// Maximum egress bytes per minute
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub max_bytes_per_minute: Option<u64>,
31    /// Maximum HTTP read requests per minute
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub max_http_requests_per_minute: Option<u32>,
34    /// Maximum account addresses accepted in one HTTP batch read
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub max_http_batch_addresses: Option<u32>,
37    /// Maximum transaction inspection requests per minute
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub max_transaction_inspect_requests_per_minute: Option<u32>,
40    /// Maximum transaction submissions per minute
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub max_transaction_send_requests_per_minute: Option<u32>,
43    /// Maximum signature status requests per minute
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub max_transaction_status_requests_per_minute: Option<u32>,
46    /// Maximum encoded HTTP request body size for transaction routes
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub max_transaction_request_bytes: Option<u32>,
49    /// Maximum decoded Solana message or transaction size
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub max_transaction_bytes: Option<u32>,
52    /// Maximum concurrent transaction operations for this subject
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub max_transaction_concurrency: Option<u32>,
55}
56
57/// Session token claims
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct SessionClaims {
60    /// Issuer - who issued this token
61    pub iss: String,
62    /// Subject - who this token is for
63    pub sub: String,
64    /// Audience - intended recipient (e.g., deployment ID)
65    pub aud: String,
66    /// Issued at (Unix timestamp)
67    pub iat: u64,
68    /// Not valid before (Unix timestamp)
69    pub nbf: u64,
70    /// Expiration time (Unix timestamp)
71    pub exp: u64,
72    /// JWT ID - unique identifier for this token
73    pub jti: String,
74    /// Scope - permissions granted
75    pub scope: String,
76    /// Metering key - for usage attribution
77    pub metering_key: String,
78    /// Deployment ID (optional)
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub deployment_id: Option<String>,
81    /// Origin binding (optional, defense-in-depth)
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub origin: Option<String>,
84    /// Client IP binding (optional, for high-security scenarios)
85    #[serde(skip_serializing_if = "Option::is_none", rename = "client_ip")]
86    pub client_ip: Option<String>,
87    /// Resource limits
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub limits: Option<Limits>,
90    /// Plan identifier (optional)
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub plan: Option<String>,
93    /// Key class (secret vs publishable)
94    #[serde(rename = "key_class")]
95    pub key_class: KeyClass,
96}
97
98impl SessionClaims {
99    /// Create a new session claims builder
100    pub fn builder(
101        iss: impl Into<String>,
102        sub: impl Into<String>,
103        aud: impl Into<String>,
104    ) -> SessionClaimsBuilder {
105        SessionClaimsBuilder::new(iss, sub, aud)
106    }
107
108    /// Check if the token is expired
109    pub fn is_expired(&self, now: u64) -> bool {
110        self.exp <= now
111    }
112
113    /// Check if the token is valid (not before issued)
114    pub fn is_valid(&self, now: u64) -> bool {
115        self.nbf <= now && self.iat <= now
116    }
117}
118
119/// Builder for SessionClaims
120pub struct SessionClaimsBuilder {
121    iss: String,
122    sub: String,
123    aud: String,
124    iat: u64,
125    nbf: u64,
126    exp: u64,
127    jti: String,
128    scope: String,
129    metering_key: String,
130    deployment_id: Option<String>,
131    origin: Option<String>,
132    client_ip: Option<String>,
133    limits: Option<Limits>,
134    plan: Option<String>,
135    key_class: KeyClass,
136}
137
138impl SessionClaimsBuilder {
139    fn new(iss: impl Into<String>, sub: impl Into<String>, aud: impl Into<String>) -> Self {
140        use std::time::{SystemTime, UNIX_EPOCH};
141        let now = SystemTime::now()
142            .duration_since(UNIX_EPOCH)
143            .expect("time should not be before epoch")
144            .as_secs();
145
146        Self {
147            iss: iss.into(),
148            sub: sub.into(),
149            aud: aud.into(),
150            iat: now,
151            nbf: now,
152            exp: now + crate::DEFAULT_SESSION_TTL_SECONDS,
153            jti: uuid::Uuid::new_v4().to_string(),
154            scope: "read".to_string(),
155            metering_key: String::new(),
156            deployment_id: None,
157            origin: None,
158            client_ip: None,
159            limits: None,
160            plan: None,
161            key_class: KeyClass::Publishable,
162        }
163    }
164
165    pub fn with_ttl(mut self, ttl_seconds: u64) -> Self {
166        self.exp = self.iat + ttl_seconds;
167        self
168    }
169
170    pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
171        self.scope = scope.into();
172        self
173    }
174
175    pub fn with_metering_key(mut self, key: impl Into<String>) -> Self {
176        self.metering_key = key.into();
177        self
178    }
179
180    pub fn with_deployment_id(mut self, id: impl Into<String>) -> Self {
181        self.deployment_id = Some(id.into());
182        self
183    }
184
185    pub fn with_origin(mut self, origin: impl Into<String>) -> Self {
186        self.origin = Some(origin.into());
187        self
188    }
189
190    pub fn with_client_ip(mut self, client_ip: impl Into<String>) -> Self {
191        self.client_ip = Some(client_ip.into());
192        self
193    }
194
195    pub fn with_limits(mut self, limits: Limits) -> Self {
196        self.limits = Some(limits);
197        self
198    }
199
200    pub fn with_plan(mut self, plan: impl Into<String>) -> Self {
201        self.plan = Some(plan.into());
202        self
203    }
204
205    pub fn with_key_class(mut self, key_class: KeyClass) -> Self {
206        self.key_class = key_class;
207        self
208    }
209
210    pub fn with_jti(mut self, jti: impl Into<String>) -> Self {
211        self.jti = jti.into();
212        self
213    }
214
215    pub fn build(self) -> SessionClaims {
216        SessionClaims {
217            iss: self.iss,
218            sub: self.sub,
219            aud: self.aud,
220            iat: self.iat,
221            nbf: self.nbf,
222            exp: self.exp,
223            jti: self.jti,
224            scope: self.scope,
225            metering_key: self.metering_key,
226            deployment_id: self.deployment_id,
227            origin: self.origin,
228            client_ip: self.client_ip,
229            limits: self.limits,
230            plan: self.plan,
231            key_class: self.key_class,
232        }
233    }
234}
235
236/// Auth context extracted from a verified token
237#[derive(Debug, Clone)]
238pub struct AuthContext {
239    /// Subject identifier
240    pub subject: String,
241    /// Issuer
242    pub issuer: String,
243    /// Key class (secret vs publishable)
244    pub key_class: KeyClass,
245    /// Metering key for usage attribution
246    pub metering_key: String,
247    /// Deployment ID binding
248    pub deployment_id: Option<String>,
249    /// Token expiration time
250    pub expires_at: u64,
251    /// Granted scope
252    pub scope: String,
253    /// Resource limits
254    pub limits: Limits,
255    /// Plan or access tier associated with the session
256    pub plan: Option<String>,
257    /// Origin binding
258    pub origin: Option<String>,
259    /// Client IP binding
260    pub client_ip: Option<String>,
261    /// JWT ID
262    pub jti: String,
263}
264
265impl AuthContext {
266    /// Test an exact whitespace-delimited scope. Scopes never imply one another.
267    pub fn has_scope(&self, required: &str) -> bool {
268        self.scope.split_whitespace().any(|scope| scope == required)
269    }
270
271    /// Create AuthContext from verified claims
272    pub fn from_claims(claims: SessionClaims) -> Self {
273        Self {
274            subject: claims.sub,
275            issuer: claims.iss,
276            key_class: claims.key_class,
277            metering_key: claims.metering_key,
278            deployment_id: claims.deployment_id,
279            expires_at: claims.exp,
280            scope: claims.scope,
281            limits: claims.limits.unwrap_or_default(),
282            plan: claims.plan,
283            origin: claims.origin,
284            client_ip: claims.client_ip,
285            jti: claims.jti,
286        }
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn scopes_are_exact_and_independent() {
296        let context = AuthContext::from_claims(
297            SessionClaims::builder("issuer", "subject", "audience")
298                .with_scope("read transaction:inspect transaction:send-extra")
299                .build(),
300        );
301
302        assert!(context.has_scope("read"));
303        assert!(context.has_scope("transaction:inspect"));
304        assert!(!context.has_scope("transaction:send"));
305        assert!(!context.has_scope("transaction"));
306    }
307
308    #[test]
309    fn old_limits_claims_remain_deserializable() {
310        let limits: Limits = serde_json::from_value(serde_json::json!({
311            "max_connections": 2
312        }))
313        .unwrap();
314
315        assert_eq!(limits.max_connections, Some(2));
316        assert_eq!(limits.max_transaction_bytes, None);
317    }
318
319    #[test]
320    fn transaction_limits_round_trip_additively() {
321        let limits = Limits {
322            max_transaction_inspect_requests_per_minute: Some(120),
323            max_transaction_send_requests_per_minute: Some(12),
324            max_transaction_status_requests_per_minute: Some(240),
325            max_transaction_request_bytes: Some(4096),
326            max_transaction_bytes: Some(1232),
327            max_transaction_concurrency: Some(4),
328            ..Limits::default()
329        };
330        let value = serde_json::to_value(&limits).unwrap();
331        let decoded: Limits = serde_json::from_value(value).unwrap();
332
333        assert_eq!(decoded.max_transaction_bytes, Some(1232));
334        assert_eq!(decoded.max_transaction_concurrency, Some(4));
335    }
336}