Skip to main content

chainstream_sdk/
wallet_auth.rs

1//! Authentication middleware for ChainStream API.
2//!
3//! Supports three modes:
4//! - API Key: `X-API-KEY` header (via `ApiKeyAuthMiddleware`)
5//! - Wallet signature: `X-Wallet-*` headers for x402 (via `WalletAuthMiddleware`)
6//! - Bearer token: `Authorization: Bearer` header (via `BearerAuthMiddleware` in tests)
7
8use async_trait::async_trait;
9use rand::Rng;
10use std::time::{SystemTime, UNIX_EPOCH};
11
12const SIGNATURE_PREFIX: &str = "chainstream";
13
14/// Trait for wallet signers (EVM or Solana).
15#[async_trait]
16pub trait WalletSigner: Send + Sync {
17    /// Returns "evm" or "solana".
18    fn chain(&self) -> &str;
19    /// Returns the wallet address (hex for EVM, Base58 for Solana).
20    fn address(&self) -> &str;
21    /// Sign a message and return the signature string.
22    async fn sign_message(&self, message: &str) -> Result<String, Box<dyn std::error::Error + Send + Sync>>;
23}
24
25/// Build the message that must be signed for wallet auth.
26pub fn build_sign_message(chain: &str, address: &str, timestamp: &str, nonce: &str) -> String {
27    format!("{SIGNATURE_PREFIX}:{chain}:{address}:{timestamp}:{nonce}")
28}
29
30fn generate_nonce() -> String {
31    let bytes: [u8; 16] = rand::thread_rng().gen();
32    hex::encode(bytes)
33}
34
35/// Wallet auth headers for HTTP requests.
36pub struct WalletAuthHeaders {
37    pub address: String,
38    pub chain: String,
39    pub signature: String,
40    pub timestamp: String,
41    pub nonce: String,
42}
43
44/// Generate X-Wallet-* headers for a request.
45pub async fn create_wallet_auth_headers(
46    signer: &dyn WalletSigner,
47) -> Result<WalletAuthHeaders, Box<dyn std::error::Error + Send + Sync>> {
48    let timestamp = SystemTime::now()
49        .duration_since(UNIX_EPOCH)?
50        .as_secs()
51        .to_string();
52    let nonce = generate_nonce();
53    let message = build_sign_message(signer.chain(), signer.address(), &timestamp, &nonce);
54    let signature = signer.sign_message(&message).await?;
55
56    Ok(WalletAuthHeaders {
57        address: signer.address().to_string(),
58        chain: signer.chain().to_string(),
59        signature,
60        timestamp,
61        nonce,
62    })
63}
64
65/// Build a SIWX (EIP-4361) message for authentication.
66pub fn build_siwx_message(address: &str, chain: &str, nonce: &str, issued_at: &str, expires_at: &str) -> String {
67    let chain_label = if chain == "solana" { "Solana" } else { "Ethereum" };
68    let chain_id = if chain == "solana" { "mainnet" } else { "8453" };
69
70    format!(
71        "{domain} wants you to sign in with your {chain_label} account:\n\
72         {address}\n\n\
73         Sign in to ChainStream API\n\n\
74         URI: https://{domain}\n\
75         Version: 1\n\
76         Chain ID: {chain_id}\n\
77         Nonce: {nonce}\n\
78         Issued At: {issued_at}\n\
79         Expiration Time: {expires_at}",
80        domain = "api.chainstream.io",
81        chain_label = chain_label,
82        address = address,
83        chain_id = chain_id,
84        nonce = nonce,
85        issued_at = issued_at,
86        expires_at = expires_at,
87    )
88}
89
90/// Middleware for reqwest that adds Authorization: SIWX header.
91/// Uses SIWX token (sign once, reuse for 1h) instead of per-request X-Wallet-* headers.
92pub struct SiwxAuthMiddleware {
93    signer: Box<dyn WalletSigner>,
94}
95
96impl SiwxAuthMiddleware {
97    pub fn new(signer: Box<dyn WalletSigner>) -> Self {
98        Self { signer }
99    }
100}
101
102#[async_trait]
103impl reqwest_middleware::Middleware for SiwxAuthMiddleware {
104    async fn handle(
105        &self,
106        mut req: reqwest::Request,
107        extensions: &mut http::Extensions,
108        next: reqwest_middleware::Next<'_>,
109    ) -> reqwest_middleware::Result<reqwest::Response> {
110        let nonce = generate_nonce();
111        let now = chrono::Utc::now();
112        let expires_at = now + chrono::Duration::hours(1);
113        let issued_at_str = now.to_rfc3339();
114        let expires_at_str = expires_at.to_rfc3339();
115
116        let message = build_siwx_message(
117            self.signer.address(),
118            self.signer.chain(),
119            &nonce,
120            &issued_at_str,
121            &expires_at_str,
122        );
123
124        let signature = self.signer.sign_message(&message)
125            .await
126            .map_err(|e| reqwest_middleware::Error::Middleware(anyhow::anyhow!("{e}")))?;
127
128        use base64::Engine;
129        let message_b64 = base64::engine::general_purpose::STANDARD.encode(message.as_bytes());
130        let token = format!("{}.{}", message_b64, signature);
131
132        req.headers_mut()
133            .insert("Authorization", format!("SIWX {}", token).parse().unwrap());
134
135        next.run(req, extensions).await
136    }
137}
138
139/// Legacy middleware for reqwest that adds X-Wallet-* headers (deprecated).
140pub struct WalletAuthMiddleware {
141    signer: Box<dyn WalletSigner>,
142}
143
144impl WalletAuthMiddleware {
145    pub fn new(signer: Box<dyn WalletSigner>) -> Self {
146        Self { signer }
147    }
148}
149
150#[async_trait]
151impl reqwest_middleware::Middleware for WalletAuthMiddleware {
152    async fn handle(
153        &self,
154        mut req: reqwest::Request,
155        extensions: &mut http::Extensions,
156        next: reqwest_middleware::Next<'_>,
157    ) -> reqwest_middleware::Result<reqwest::Response> {
158        let headers = create_wallet_auth_headers(self.signer.as_ref())
159            .await
160            .map_err(|e| reqwest_middleware::Error::Middleware(anyhow::anyhow!("{e}")))?;
161
162        let h = req.headers_mut();
163        h.insert("X-Wallet-Address", headers.address.parse().unwrap());
164        h.insert("X-Wallet-Chain", headers.chain.parse().unwrap());
165        h.insert("X-Wallet-Signature", headers.signature.parse().unwrap());
166        h.insert("X-Wallet-Timestamp", headers.timestamp.parse().unwrap());
167        h.insert("X-Wallet-Nonce", headers.nonce.parse().unwrap());
168
169        next.run(req, extensions).await
170    }
171}
172
173/// Middleware for reqwest that adds X-API-KEY header.
174pub struct ApiKeyAuthMiddleware {
175    api_key: String,
176}
177
178impl ApiKeyAuthMiddleware {
179    pub fn new(api_key: impl Into<String>) -> Self {
180        Self {
181            api_key: api_key.into(),
182        }
183    }
184}
185
186#[async_trait]
187impl reqwest_middleware::Middleware for ApiKeyAuthMiddleware {
188    async fn handle(
189        &self,
190        mut req: reqwest::Request,
191        extensions: &mut http::Extensions,
192        next: reqwest_middleware::Next<'_>,
193    ) -> reqwest_middleware::Result<reqwest::Response> {
194        req.headers_mut()
195            .insert("X-API-KEY", self.api_key.parse().unwrap());
196        next.run(req, extensions).await
197    }
198}