Skip to main content

r402_http/server/
siwx.rs

1//! HTTP access-grant for `sign-in-with-x`.
2//!
3//! Valid CAIP-122 proof plus (`auth_only` or paid-address hit) skips payment
4//! and never calls facilitator `/verify`. Failed proofs continue as unpaid
5//! 402 with a fresh challenge.
6
7use std::future::Future;
8use std::sync::Arc;
9
10use axum_core::body::Body;
11use http::Request;
12use r402_extensions::siwx::{
13    EvmVerifier, PaidAddressStore, SIWX_KEY, SiwxChain, SiwxError, SiwxExtension, SiwxOrigin,
14    SiwxProof, SiwxProofError,
15};
16use r402_protocol::payment::ExtensionEntry;
17use time::OffsetDateTime;
18
19use super::hooks::{GateHooks, ProtectedRequestOutcome};
20use crate::headers::SIGN_IN_WITH_X;
21
22/// Resource-server SIWX configuration used by the HTTP gate.
23pub struct SiwxGate {
24    extension: SiwxExtension,
25    store: Arc<dyn PaidAddressStore>,
26    auth_only: bool,
27    evm: EvmVerifier,
28}
29
30impl Clone for SiwxGate {
31    #[cfg_attr(
32        not(feature = "siwx-eip1271"),
33        allow(
34            clippy::clone_on_copy,
35            reason = "EvmVerifier is Copy only without eip1271"
36        )
37    )]
38    fn clone(&self) -> Self {
39        Self {
40            extension: self.extension.clone(),
41            store: Arc::clone(&self.store),
42            auth_only: self.auth_only,
43            evm: self.evm.clone(),
44        }
45    }
46}
47
48impl std::fmt::Debug for SiwxGate {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        f.debug_struct("SiwxGate")
51            .field("origin", self.extension.origin())
52            .field("auth_only", &self.auth_only)
53            .finish_non_exhaustive()
54    }
55}
56
57impl SiwxGate {
58    /// Binds SIWX to a configured public origin and paid-address store.
59    ///
60    /// Origin is required. There is no `Host` constructor.
61    #[must_use]
62    pub fn new(origin: SiwxOrigin, store: impl PaidAddressStore + 'static) -> Self {
63        Self {
64            extension: SiwxExtension::new(origin),
65            store: Arc::new(store),
66            auth_only: false,
67            evm: EvmVerifier::new(),
68        }
69    }
70
71    /// Adds a supported authentication chain.
72    #[must_use]
73    pub fn with_chain(mut self, chain: SiwxChain) -> Self {
74        self.extension = self.extension.with_chain(chain);
75        self
76    }
77
78    /// Sets the CAIP-122 statement shown to the wallet.
79    #[must_use]
80    pub fn with_statement(mut self, statement: impl Into<compact_str::CompactString>) -> Self {
81        self.extension = self.extension.with_statement(statement);
82        self
83    }
84
85    /// Grants access on valid signature even when the address has not paid.
86    #[must_use]
87    pub const fn with_auth_only(mut self) -> Self {
88        self.auth_only = true;
89        self
90    }
91
92    /// Server-configured per-chain RPC for EIP-1271 / EIP-6492.
93    ///
94    /// `timeout` `None` keeps siwx-evm's 5s default. `Some` is applied after
95    /// `with_rpc_map`, which resets timeout.
96    #[cfg(feature = "siwx-eip1271")]
97    #[must_use]
98    pub fn with_evm_rpc_map(
99        mut self,
100        map: impl IntoIterator<Item = (u64, impl Into<String>)>,
101        timeout: Option<std::time::Duration>,
102    ) -> Self {
103        let mut evm = EvmVerifier::with_rpc_map(map);
104        if let Some(timeout) = timeout {
105            evm = evm.with_rpc_timeout(timeout);
106        }
107        self.evm = evm;
108        self
109    }
110
111    /// Whether empty price tags must not bypass the gate.
112    #[must_use]
113    pub const fn is_auth_only(&self) -> bool {
114        self.auth_only
115    }
116
117    /// Configured public origin (never `Host`).
118    #[must_use]
119    pub const fn origin(&self) -> &SiwxOrigin {
120        self.extension.origin()
121    }
122
123    /// Shared paid-address / nonce store.
124    #[must_use]
125    pub fn store(&self) -> &dyn PaidAddressStore {
126        self.store.as_ref()
127    }
128
129    /// Per-request 402 challenge entry (fresh nonce and timestamps).
130    ///
131    /// # Errors
132    ///
133    /// [`SiwxError`] when nonce or timestamp formatting fails.
134    pub fn challenge_entry(&self, path: &str) -> Result<ExtensionEntry, SiwxError> {
135        self.extension.challenge_now(path)
136    }
137
138    /// Records a successful settlement against configured origin + `path`.
139    pub fn record_success(&self, path: &str, address: &str) {
140        let key = self.extension.origin().store_key(path);
141        self.store.record_success(&key, address);
142    }
143
144    /// Wire key inserted on `PaymentRequired.extensions`.
145    #[must_use]
146    pub const fn key() -> &'static str {
147        SIWX_KEY
148    }
149
150    pub(crate) async fn try_grant(&self, header: &str, path: &str) -> bool {
151        let proof = match SiwxProof::parse_header(header) {
152            Ok(proof) => proof,
153            Err(err) => return log_parse_denied(err),
154        };
155        if let Err(err) = proof
156            .verify_at(
157                self.extension.origin(),
158                path,
159                OffsetDateTime::now_utc(),
160                &self.evm,
161            )
162            .await
163        {
164            return log_denied(err);
165        }
166        let key = self.extension.origin().store_key(path);
167        if !(self.auth_only || self.store.contains(&key, &proof.address)) {
168            return log_valid_unpaid();
169        }
170        // After verify: dummy proofs never insert. Concurrent valid
171        // replays: one insert-if-absent wins; the loser is a replay.
172        if !self.store.consume_nonce(&proof.nonce) {
173            return log_nonce_replay();
174        }
175        log_granted()
176    }
177}
178
179fn log_parse_denied(err: SiwxProofError) -> bool {
180    tracing::debug!(error = ?err, "siwx parse denied");
181    false
182}
183
184fn log_denied(err: SiwxError) -> bool {
185    tracing::debug!(code = err.as_str(), "siwx denied");
186    false
187}
188
189fn log_valid_unpaid() -> bool {
190    tracing::debug!("siwx valid unpaid");
191    false
192}
193
194fn log_nonce_replay() -> bool {
195    tracing::debug!("siwx nonce replay");
196    false
197}
198
199fn log_granted() -> bool {
200    tracing::debug!("siwx granted");
201    true
202}
203
204impl GateHooks for SiwxGate {
205    fn on_protected_request<'a>(
206        &'a self,
207        req: &'a Request<Body>,
208    ) -> impl Future<Output = ProtectedRequestOutcome> + Send + 'a {
209        let header = req
210            .headers()
211            .get(SIGN_IN_WITH_X)
212            .and_then(|v| v.to_str().ok())
213            .map(str::to_owned);
214        let path = req.uri().path().to_owned();
215        async move {
216            let Some(header) = header else {
217                return ProtectedRequestOutcome::Continue;
218            };
219            if self.try_grant(&header, &path).await {
220                ProtectedRequestOutcome::GrantAccess
221            } else {
222                ProtectedRequestOutcome::Continue
223            }
224        }
225    }
226}