Skip to main content

arete_auth/
solana_gateway.rs

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/// One exact permission understood by the Solana gateway.
9#[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/// Authorization for one regional Solana gateway binding.
33#[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    /// Validate only the stable audience and exact gateway target claims.
48    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    /// Validate verified claims against an exact gateway binding and permission.
81    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/// Failure to authorize verified claims for a Solana gateway binding.
113#[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}