Skip to main content

r402_extensions/siwx/
client.rs

1//! Client `SIGN-IN-WITH-X` header. Official `createSIWxClientExtension`.
2//!
3//! Uses `ClientExtension::on_payment_required`, not `enrich_payment_payload`.
4//! Origin is `SiwxOrigin::parse(request_url)` (402 response URL after redirects).
5
6use 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
19/// Wallet that can produce a CAIP-122 SIWX proof.
20pub trait SiwxSigner: Send + Sync {
21    /// `eip191` or `ed25519`. Matched against `supportedChains[].type`.
22    fn signature_type(&self) -> &'static str;
23
24    /// Wallet address (hex for EVM, Base58 for Solana).
25    fn address(&self) -> CompactString;
26
27    /// Signs the canonical CAIP-122 message. EVM returns `0x` hex; Solana Base58.
28    fn sign_message<'a>(
29        &'a self,
30        message: &'a str,
31    ) -> Pin<Box<dyn Future<Output = Result<CompactString, SiwxError>> + Send + 'a>>;
32}
33
34/// Client extension that signs HTTP SIWX challenges for compatible wallets.
35///
36/// Signers are tried in registration order until one matches a declared chain
37/// and produces a header. Failures are skipped (payment retry still proceeds).
38pub 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    /// Empty signer list.
58    #[must_use]
59    pub const fn new() -> Self {
60        Self {
61            signers: Vec::new(),
62        }
63    }
64
65    /// Appends a wallet signer.
66    #[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
138/// Domain and URI origin must match `request_url` after redirects.
139fn 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}