r402_extensions/siwx/
client.rs1use std::fmt::{self, Debug, Formatter};
7use std::future::Future;
8use std::pin::Pin;
9use std::sync::Arc;
10
11use compact_str::CompactString;
12use http::{HeaderMap, HeaderValue};
13use r402_client::ClientExtension;
14use r402_protocol::PaymentRequired;
15use serde_json::Value;
16
17use super::{SIWX_HTTP_HEADER, SIWX_KEY, SiwxError, SiwxOrigin, SiwxProof};
18
19pub trait SiwxSigner: Send + Sync {
21 fn signature_type(&self) -> &'static str;
23
24 fn address(&self) -> CompactString;
26
27 fn sign_message<'a>(
29 &'a self,
30 message: &'a str,
31 ) -> Pin<Box<dyn Future<Output = Result<CompactString, SiwxError>> + Send + 'a>>;
32}
33
34pub struct SiwxClientExtension {
39 signers: Vec<Arc<dyn SiwxSigner>>,
40}
41
42impl Default for SiwxClientExtension {
43 fn default() -> Self {
44 Self::new()
45 }
46}
47
48impl Debug for SiwxClientExtension {
49 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
50 f.debug_struct("SiwxClientExtension")
51 .field("signers", &self.signers.len())
52 .finish()
53 }
54}
55
56impl SiwxClientExtension {
57 #[must_use]
59 pub const fn new() -> Self {
60 Self {
61 signers: Vec::new(),
62 }
63 }
64
65 #[must_use]
67 pub fn with_signer(mut self, signer: impl SiwxSigner + 'static) -> Self {
68 self.signers.push(Arc::new(signer));
69 self
70 }
71}
72
73impl ClientExtension for SiwxClientExtension {
74 fn key(&self) -> &'static str {
75 SIWX_KEY
76 }
77
78 async fn on_payment_required(
79 &self,
80 payment_required: &PaymentRequired,
81 request_url: &str,
82 ) -> HeaderMap {
83 sign_header(self, payment_required, request_url)
84 .await
85 .map_or_else(HeaderMap::new, |encoded| siwx_header_map(&encoded))
86 }
87}
88
89fn siwx_header_map(encoded: &str) -> HeaderMap {
90 let Ok(value) = HeaderValue::from_str(encoded) else {
91 return HeaderMap::new();
92 };
93 let mut headers = HeaderMap::new();
94 let _ = headers.insert(SIWX_HTTP_HEADER, value);
95 headers
96}
97
98async fn sign_header(
99 extension: &SiwxClientExtension,
100 payment_required: &PaymentRequired,
101 request_url: &str,
102) -> Option<String> {
103 let declaration = payment_required.extensions.get(SIWX_KEY)?.to_value();
104 if !challenge_bound_to_origin(&declaration, request_url) {
105 return None;
106 }
107 let info = declaration.get("info")?;
108 let chains = declaration.get("supportedChains")?.as_array()?;
109 for signer in &extension.signers {
110 let Some((chain_id, signature_type, signature_scheme)) =
111 matching_chain(chains, signer.signature_type())
112 else {
113 continue;
114 };
115 let Some(mut proof) = proof_from_info(
116 info,
117 &chain_id,
118 &signature_type,
119 signature_scheme,
120 signer.address(),
121 ) else {
122 continue;
123 };
124 let Ok(message) = proof.signing_message() else {
125 continue;
126 };
127 let Ok(signature) = signer.sign_message(&message).await else {
128 continue;
129 };
130 proof.signature = signature;
131 if let Ok(encoded) = proof.encode_header() {
132 return Some(encoded);
133 }
134 }
135 None
136}
137
138fn challenge_bound_to_origin(declaration: &Value, request_url: &str) -> bool {
140 let Some(info) = declaration.get("info") else {
141 return false;
142 };
143 let Some(domain) = info.get("domain").and_then(Value::as_str) else {
144 return false;
145 };
146 let Some(uri) = info.get("uri").and_then(Value::as_str) else {
147 return false;
148 };
149 let Ok(request_origin) = SiwxOrigin::parse(request_url) else {
150 return false;
151 };
152 let Ok(uri_origin) = SiwxOrigin::parse(uri) else {
153 return false;
154 };
155 domain == request_origin.domain() && uri_origin.as_str() == request_origin.as_str()
156}
157
158fn matching_chain(
159 chains: &[Value],
160 signature_type: &str,
161) -> Option<(CompactString, CompactString, Option<CompactString>)> {
162 for chain in chains {
163 let Some(chain_type) = chain.get("type").and_then(Value::as_str) else {
164 continue;
165 };
166 if chain_type != signature_type {
167 continue;
168 }
169 let Some(chain_id) = chain.get("chainId").and_then(Value::as_str) else {
170 continue;
171 };
172 let signature_scheme = chain
173 .get("signatureScheme")
174 .and_then(Value::as_str)
175 .map(CompactString::from);
176 return Some((chain_id.into(), chain_type.into(), signature_scheme));
177 }
178 None
179}
180
181fn proof_from_info(
182 info: &Value,
183 chain_id: &str,
184 signature_type: &str,
185 signature_scheme: Option<CompactString>,
186 address: CompactString,
187) -> Option<SiwxProof> {
188 Some(SiwxProof {
189 address,
190 signature: CompactString::default(),
191 domain: required_str(info, "domain")?,
192 uri: required_str(info, "uri")?,
193 version: required_str(info, "version")?,
194 nonce: required_str(info, "nonce")?,
195 issued_at: required_str(info, "issuedAt")?,
196 expiration_time: opt_str(info, "expirationTime"),
197 not_before: opt_str(info, "notBefore"),
198 statement: opt_str(info, "statement"),
199 request_id: opt_str(info, "requestId"),
200 resources: opt_str_array(info, "resources"),
201 chain_id: chain_id.into(),
202 signature_type: signature_type.into(),
203 signature_scheme,
204 })
205}
206
207fn required_str(info: &Value, name: &str) -> Option<CompactString> {
208 info.get(name)
209 .and_then(Value::as_str)
210 .map(CompactString::from)
211}
212
213fn opt_str(info: &Value, name: &str) -> Option<CompactString> {
214 info.get(name)
215 .and_then(Value::as_str)
216 .map(CompactString::from)
217}
218
219fn opt_str_array(info: &Value, name: &str) -> Vec<CompactString> {
220 info.get(name)
221 .and_then(Value::as_array)
222 .map(|arr| {
223 arr.iter()
224 .filter_map(Value::as_str)
225 .map(CompactString::from)
226 .collect()
227 })
228 .unwrap_or_default()
229}