1use thiserror::Error;
2
3use crate::{
4 AuthContext, KeyClass, Limits, TargetKind, SCOPE_READ, SCOPE_TRANSACTION_INSPECT,
5 SCOPE_TRANSACTION_SEND, SOLANA_GATEWAY_AUDIENCE,
6};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum SolanaGatewayScope {
11 Read,
12 TransactionInspect,
13 TransactionSend,
14}
15
16impl SolanaGatewayScope {
17 pub const fn as_str(self) -> &'static str {
18 match self {
19 Self::Read => SCOPE_READ,
20 Self::TransactionInspect => SCOPE_TRANSACTION_INSPECT,
21 Self::TransactionSend => SCOPE_TRANSACTION_SEND,
22 }
23 }
24}
25
26impl std::fmt::Display for SolanaGatewayScope {
27 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28 formatter.write_str(self.as_str())
29 }
30}
31
32#[derive(Debug, Clone)]
34pub struct SolanaGatewayAuthorization {
35 pub subject: String,
36 pub issuer: String,
37 pub key_class: KeyClass,
38 pub metering_key: String,
39 pub target_id: String,
40 pub limits: Limits,
41 pub plan: Option<String>,
42 pub expires_at: u64,
43 pub jti: String,
44}
45
46impl SolanaGatewayAuthorization {
47 pub fn validate_target(
49 context: &AuthContext,
50 expected_target_id: &str,
51 ) -> Result<(), SolanaGatewayAuthorizationError> {
52 if context.audience != SOLANA_GATEWAY_AUDIENCE {
53 return Err(SolanaGatewayAuthorizationError::InvalidAudience {
54 actual: context.audience.clone(),
55 });
56 }
57
58 match context.target_kind {
59 Some(TargetKind::SolanaGatewayBinding) => {}
60 Some(actual) => {
61 return Err(SolanaGatewayAuthorizationError::InvalidTargetKind { actual });
62 }
63 None => return Err(SolanaGatewayAuthorizationError::MissingClaim("targetKind")),
64 }
65
66 let target_id = context
67 .target_id
68 .as_deref()
69 .filter(|target_id| !target_id.is_empty())
70 .ok_or(SolanaGatewayAuthorizationError::MissingClaim("targetId"))?;
71 if target_id != expected_target_id {
72 return Err(SolanaGatewayAuthorizationError::TargetIdMismatch {
73 expected: expected_target_id.to_string(),
74 actual: target_id.to_string(),
75 });
76 }
77 Ok(())
78 }
79
80 pub fn try_from_context(
82 context: &AuthContext,
83 expected_target_id: &str,
84 required_scope: SolanaGatewayScope,
85 ) -> Result<Self, SolanaGatewayAuthorizationError> {
86 Self::validate_target(context, expected_target_id)?;
87 let target_id = context
88 .target_id
89 .as_deref()
90 .expect("validated gateway target ID");
91
92 if !context.has_scope(required_scope.as_str()) {
93 return Err(SolanaGatewayAuthorizationError::MissingScope {
94 required: required_scope,
95 });
96 }
97
98 Ok(Self {
99 subject: context.subject.clone(),
100 issuer: context.issuer.clone(),
101 key_class: context.key_class,
102 metering_key: context.metering_key.clone(),
103 target_id: target_id.to_string(),
104 limits: context.limits.clone(),
105 plan: context.plan.clone(),
106 expires_at: context.expires_at,
107 jti: context.jti.clone(),
108 })
109 }
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Error)]
114pub enum SolanaGatewayAuthorizationError {
115 #[error("invalid Solana gateway audience: {actual}")]
116 InvalidAudience { actual: String },
117 #[error("missing required Solana gateway claim: {0}")]
118 MissingClaim(&'static str),
119 #[error("invalid Solana gateway target kind: {actual:?}")]
120 InvalidTargetKind { actual: TargetKind },
121 #[error("Solana gateway target mismatch: expected {expected}, got {actual}")]
122 TargetIdMismatch { expected: String, actual: String },
123 #[error("Solana gateway authorization requires the {required} scope")]
124 MissingScope { required: SolanaGatewayScope },
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130 use crate::{SessionClaims, SigningKey, TokenSigner, TokenVerifier};
131
132 const TARGET_ID: &str = "gateway-us-east-1";
133
134 fn claims(scope: &str) -> SessionClaims {
135 SessionClaims::solana_gateway_builder("issuer", "user:1", TARGET_ID)
136 .with_scope(scope)
137 .with_metering_key("api_key:42")
138 .build()
139 }
140
141 fn context(scope: &str) -> AuthContext {
142 AuthContext::from_claims(claims(scope))
143 }
144
145 #[test]
146 fn signed_gateway_claims_validate_for_each_exact_scope() {
147 let signing_key = SigningKey::generate();
148 let token = TokenSigner::new(signing_key.clone(), "issuer")
149 .sign(claims(
150 "read transaction:inspect transaction:send transaction:send-extra",
151 ))
152 .unwrap();
153 let context = TokenVerifier::new(
154 signing_key.verifying_key(),
155 "issuer",
156 SOLANA_GATEWAY_AUDIENCE,
157 )
158 .verify(&token, None, None)
159 .unwrap();
160
161 for scope in [
162 SolanaGatewayScope::Read,
163 SolanaGatewayScope::TransactionInspect,
164 SolanaGatewayScope::TransactionSend,
165 ] {
166 let authorization =
167 SolanaGatewayAuthorization::try_from_context(&context, TARGET_ID, scope).unwrap();
168 assert_eq!(authorization.target_id, TARGET_ID);
169 assert_eq!(authorization.metering_key, "api_key:42");
170 }
171 }
172
173 #[test]
174 fn similarly_named_scope_does_not_authorize_send() {
175 let error = SolanaGatewayAuthorization::try_from_context(
176 &context("transaction:send-extra"),
177 TARGET_ID,
178 SolanaGatewayScope::TransactionSend,
179 )
180 .unwrap_err();
181
182 assert_eq!(
183 error,
184 SolanaGatewayAuthorizationError::MissingScope {
185 required: SolanaGatewayScope::TransactionSend
186 }
187 );
188 }
189
190 #[test]
191 fn wrong_audience_kind_or_target_is_rejected() {
192 let mut wrong_audience = context(SCOPE_READ);
193 wrong_audience.audience = "deployment-1".into();
194 assert!(matches!(
195 SolanaGatewayAuthorization::try_from_context(
196 &wrong_audience,
197 TARGET_ID,
198 SolanaGatewayScope::Read
199 ),
200 Err(SolanaGatewayAuthorizationError::InvalidAudience { .. })
201 ));
202
203 let deployment = AuthContext::from_claims(
204 SessionClaims::builder("issuer", "user:1", SOLANA_GATEWAY_AUDIENCE)
205 .with_target(TargetKind::Deployment, TARGET_ID)
206 .build(),
207 );
208 assert!(matches!(
209 SolanaGatewayAuthorization::try_from_context(
210 &deployment,
211 TARGET_ID,
212 SolanaGatewayScope::Read
213 ),
214 Err(SolanaGatewayAuthorizationError::InvalidTargetKind {
215 actual: TargetKind::Deployment
216 })
217 ));
218
219 assert!(matches!(
220 SolanaGatewayAuthorization::try_from_context(
221 &context(SCOPE_READ),
222 "gateway-eu-west-1",
223 SolanaGatewayScope::Read
224 ),
225 Err(SolanaGatewayAuthorizationError::TargetIdMismatch { .. })
226 ));
227 }
228}