1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
5#[serde(rename_all = "snake_case")]
6pub enum KeyClass {
7 Secret,
9 Publishable,
11}
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "kebab-case")]
16pub enum TargetKind {
17 Deployment,
19 ProgramReadBinding,
21 SolanaGatewayBinding,
23}
24
25#[derive(Debug, Clone, Default, Serialize, Deserialize)]
27pub struct Limits {
28 #[serde(skip_serializing_if = "Option::is_none")]
30 pub max_connections: Option<u32>,
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub max_subscriptions: Option<u32>,
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub max_snapshot_rows: Option<u32>,
37 #[serde(skip_serializing_if = "Option::is_none")]
39 pub max_messages_per_minute: Option<u32>,
40 #[serde(skip_serializing_if = "Option::is_none")]
42 pub max_bytes_per_minute: Option<u64>,
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub max_http_requests_per_minute: Option<u32>,
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub max_http_batch_addresses: Option<u32>,
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub max_transaction_inspect_requests_per_minute: Option<u32>,
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub max_transaction_send_requests_per_minute: Option<u32>,
55 #[serde(skip_serializing_if = "Option::is_none")]
57 pub max_transaction_status_requests_per_minute: Option<u32>,
58 #[serde(skip_serializing_if = "Option::is_none")]
60 pub max_transaction_request_bytes: Option<u32>,
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub max_transaction_bytes: Option<u32>,
64 #[serde(skip_serializing_if = "Option::is_none")]
66 pub max_transaction_concurrency: Option<u32>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct SessionClaims {
72 pub iss: String,
74 pub sub: String,
76 pub aud: String,
78 pub iat: u64,
80 pub nbf: u64,
82 pub exp: u64,
84 pub jti: String,
86 pub scope: String,
88 pub metering_key: String,
90 #[serde(skip_serializing_if = "Option::is_none")]
92 pub deployment_id: Option<String>,
93 #[serde(
95 default,
96 rename = "targetKind",
97 skip_serializing_if = "Option::is_none"
98 )]
99 pub target_kind: Option<TargetKind>,
100 #[serde(default, rename = "targetId", skip_serializing_if = "Option::is_none")]
102 pub target_id: Option<String>,
103 #[serde(default, rename = "programId", skip_serializing_if = "Option::is_none")]
105 pub program_id: Option<String>,
106 #[serde(
108 default,
109 rename = "programReleaseHash",
110 skip_serializing_if = "Option::is_none"
111 )]
112 pub program_release_hash: Option<String>,
113 #[serde(skip_serializing_if = "Option::is_none")]
115 pub origin: Option<String>,
116 #[serde(skip_serializing_if = "Option::is_none", rename = "client_ip")]
118 pub client_ip: Option<String>,
119 #[serde(skip_serializing_if = "Option::is_none")]
121 pub limits: Option<Limits>,
122 #[serde(skip_serializing_if = "Option::is_none")]
124 pub plan: Option<String>,
125 #[serde(rename = "key_class")]
127 pub key_class: KeyClass,
128}
129
130impl SessionClaims {
131 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 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 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 pub fn is_expired(&self, now: u64) -> bool {
167 self.exp <= now
168 }
169
170 pub fn is_valid(&self, now: u64) -> bool {
172 self.nbf <= now && self.iat <= now
173 }
174}
175
176pub 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 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 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 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 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 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#[derive(Debug, Clone)]
351pub struct AuthContext {
352 pub subject: String,
354 pub issuer: String,
356 pub audience: String,
358 pub key_class: KeyClass,
360 pub metering_key: String,
362 pub deployment_id: Option<String>,
364 pub target_kind: Option<TargetKind>,
366 pub target_id: Option<String>,
368 pub program_id: Option<String>,
370 pub program_release_hash: Option<String>,
372 pub expires_at: u64,
374 pub scope: String,
376 pub limits: Limits,
378 pub plan: Option<String>,
380 pub origin: Option<String>,
382 pub client_ip: Option<String>,
384 pub jti: String,
386}
387
388impl AuthContext {
389 pub fn has_scope(&self, required: &str) -> bool {
391 self.scope.split_whitespace().any(|scope| scope == required)
392 }
393
394 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}