Skip to main content

better_auth_api/plugins/siwe/
mod.rs

1mod request;
2use std::{future::Future, pin::Pin, sync::Arc};
3
4use better_auth_core::types::CreateWalletAddress;
5use better_auth_core::{
6    AuthContext, AuthError, AuthRequest, AuthResponse, AuthResult, AuthSchema, AuthSession,
7    AuthUser, CreateAccount, CreateUser, CreateVerification, RequestMeta,
8};
9use chrono::{DateTime, Duration, NaiveDate, Utc};
10use serde_json::{Value, json};
11use sha3::{Digest, Keccak256};
12
13use super::helpers::{SessionIssueError, issue_user_session};
14
15type CallbackFuture<T> = Pin<Box<dyn Future<Output = AuthResult<T>> + Send>>;
16type NonceCallback = dyn Fn() -> CallbackFuture<String> + Send + Sync;
17type VerifyCallback = dyn Fn(SiweVerification) -> CallbackFuture<bool> + Send + Sync;
18type EnsCallback = dyn Fn(String) -> CallbackFuture<EnsProfile> + Send + Sync;
19
20/// Validated signed-message context passed to the application's SIWE verifier.
21#[derive(Debug, Clone)]
22pub struct SiweVerification {
23    /// Original ERC-4361 message, without normalization.
24    pub message: String,
25    /// Signature supplied by the wallet.
26    pub signature: String,
27    /// EIP-55 checksum address parsed from the message.
28    pub address: String,
29    /// Positive chain identifier parsed from the message.
30    pub chain_id: i64,
31    /// Upstream CACAO context for application verifiers.
32    pub cacao: Value,
33}
34/// Optional ENS attributes used when creating a wallet identity.
35#[derive(Debug, Clone, Default)]
36pub struct EnsProfile {
37    /// Display name; omission uses the checksum address.
38    pub name: Option<String>,
39    /// Avatar URL; omission uses an empty image value.
40    pub avatar: Option<String>,
41}
42
43/// Sign-In with Ethereum. The required verifier must authenticate the signature.
44#[derive(Clone)]
45pub struct SiwePlugin {
46    domain: String,
47    get_nonce: Arc<NonceCallback>,
48    verify_message: Arc<VerifyCallback>,
49    ens_lookup: Option<Arc<EnsCallback>>,
50    anonymous: bool,
51    email_domain_name: Option<String>,
52}
53impl SiwePlugin {
54    /// Configure the expected domain, nonce generator, and signature verifier.
55    pub fn new<N, NF, V, VF>(domain: impl Into<String>, nonce: N, verify: V) -> Self
56    where
57        N: Fn() -> NF + Send + Sync + 'static,
58        NF: Future<Output = AuthResult<String>> + Send + 'static,
59        V: Fn(SiweVerification) -> VF + Send + Sync + 'static,
60        VF: Future<Output = AuthResult<bool>> + Send + 'static,
61    {
62        Self {
63            domain: domain.into(),
64            get_nonce: Arc::new(move || Box::pin(nonce())),
65            verify_message: Arc::new(move |message| Box::pin(verify(message))),
66            ens_lookup: None,
67            anonymous: true,
68            email_domain_name: None,
69        }
70    }
71    /// Permit verification without email; enabled by default.
72    pub fn anonymous(mut self, anonymous: bool) -> Self {
73        self.anonymous = anonymous;
74        self
75    }
76    /// Use this domain for generated wallet placeholder emails.
77    pub fn email_domain_name(mut self, domain: impl Into<String>) -> Self {
78        self.email_domain_name = Some(domain.into());
79        self
80    }
81    /// Resolve display attributes for a newly registered checksum address.
82    pub fn ens_lookup<F, Fut>(mut self, callback: F) -> Self
83    where
84        F: Fn(String) -> Fut + Send + Sync + 'static,
85        Fut: Future<Output = AuthResult<EnsProfile>> + Send + 'static,
86    {
87        self.ens_lookup = Some(Arc::new(move |address| Box::pin(callback(address))));
88        self
89    }
90
91    async fn nonce(
92        &self,
93        req: &AuthRequest,
94        ctx: &AuthContext<impl AuthSchema>,
95    ) -> AuthResult<AuthResponse> {
96        if req.validated_body::<()>().is_none() {
97            let _ = request::nonce(req)?;
98        }
99        let nonce = (self.get_nonce)().await?;
100        if !valid_nonce(&nonce) {
101            return Ok(AuthResponse::json(
102                500,
103                &json!({"message":"SIWE getNonce must return an ERC-4361 nonce: 8-250 alphanumeric characters.","status":500,"code":"SIWE_INVALID_NONCE"}),
104            )?);
105        }
106        let _ = ctx
107            .database
108            .create_verification(CreateVerification {
109                identifier: (format!("siwe:{nonce}")).into(),
110                value: (nonce.clone()).into(),
111                expires_at: (Utc::now() + Duration::minutes(15)).into(),
112                ..Default::default()
113            })
114            .await?;
115        Ok(AuthResponse::json(200, &json!({"nonce":nonce}))?)
116    }
117    async fn verify(
118        &self,
119        req: &AuthRequest,
120        ctx: &AuthContext<impl AuthSchema>,
121    ) -> AuthResult<AuthResponse> {
122        let body = request::read(req, self.anonymous)?;
123        match self
124            .verify_inner(
125                req,
126                ctx,
127                &body.message,
128                &body.signature,
129                body.email.as_deref(),
130            )
131            .await
132        {
133            Ok(response) => Ok(response),
134            Err(cause @ (AuthError::Upstream { .. } | AuthError::BannedUser(_))) => Err(cause),
135            Err(cause) => Ok(AuthResponse::json(
136                401,
137                &json!({"message":"Something went wrong. Please try again later.","error":cause.to_string(),"status":401}),
138            )?),
139        }
140    }
141    async fn verify_inner(
142        &self,
143        req: &AuthRequest,
144        ctx: &AuthContext<impl AuthSchema>,
145        message: &str,
146        signature: &str,
147        email: Option<&str>,
148    ) -> AuthResult<AuthResponse> {
149        let parsed = ParsedMessage::parse(message);
150        let Some(nonce) = parsed.nonce.filter(|nonce| valid_nonce(nonce)) else {
151            return mismatch();
152        };
153        if ctx
154            .database
155            .consume_verification_by_identifier(&format!("siwe:{nonce}"))
156            .await?
157            .is_none()
158        {
159            return unauthorized(
160                "Unauthorized: Invalid or expired nonce",
161                Some("UNAUTHORIZED_INVALID_OR_EXPIRED_NONCE"),
162            );
163        }
164        let Some(address) = parsed.address.and_then(checksum_address) else {
165            return mismatch();
166        };
167        let Some(chain_id) = parsed.chain_id.filter(|chain| *chain > 0) else {
168            return mismatch();
169        };
170        if parsed
171            .domain
172            .is_none_or(|domain| normalize_domain(domain) != normalize_domain(&self.domain))
173        {
174            return mismatch();
175        }
176        if parsed
177            .expiration_time
178            .and_then(parse_date)
179            .is_some_and(|time| Utc::now() >= time)
180        {
181            return unauthorized(
182                "Unauthorized: SIWE message has expired",
183                Some("UNAUTHORIZED_SIWE_MESSAGE_EXPIRED"),
184            );
185        }
186        if parsed
187            .not_before
188            .and_then(parse_date)
189            .is_some_and(|time| Utc::now() < time)
190        {
191            return unauthorized(
192                "Unauthorized: SIWE message is not yet valid",
193                Some("UNAUTHORIZED_SIWE_MESSAGE_NOT_YET_VALID"),
194            );
195        }
196        let arguments = SiweVerification {
197            message: message.to_owned(),
198            signature: signature.to_owned(),
199            address: address.clone(),
200            chain_id,
201            cacao: json!({"h":{"t":"caip122"},"p":{"domain":self.domain,"aud":self.domain,"nonce":nonce,"iss":self.domain,"version":"1"},"s":{"t":"eip191","s":signature}}),
202        };
203        if !(self.verify_message)(arguments).await? {
204            return unauthorized("Unauthorized: Invalid SIWE signature", None);
205        }
206        let exact_wallet = ctx
207            .database
208            .get_wallet_address(&address, Some(chain_id))
209            .await?;
210        let wallet = match exact_wallet.as_ref() {
211            Some(wallet) => Some(wallet.clone()),
212            None => ctx.database.get_wallet_address(&address, None).await?,
213        };
214        let user = match wallet {
215            Some(wallet) => ctx.database.get_user_by_id_field(&wallet.user_id).await?,
216            None => None,
217        };
218        let new_user = user.is_none();
219        let user = if let Some(user) = user {
220            user
221        } else {
222            let wallet_email = match &self.email_domain_name {
223                Some(domain) => format!("{address}@{domain}"),
224                None => format!("{address}@siwe.placeholder.invalid"),
225            };
226            let supplied_email = email.map(str::to_lowercase).filter(|_| !self.anonymous);
227            let mut email_claim = None;
228            let mut user_email = wallet_email.clone();
229            if let Some(email) = &supplied_email {
230                let identifier = format!("siwe-email-claim-{email}");
231                let reserved = ctx
232                    .database
233                    .reserve_verification_value(CreateVerification {
234                        identifier: (identifier.clone()).into(),
235                        value: (address.clone()).into(),
236                        expires_at: (Utc::now() + Duration::minutes(1)).into(),
237                        ..Default::default()
238                    })
239                    .await;
240                let reserved = match reserved {
241                    Ok(reserved) => reserved,
242                    Err(cause) => {
243                        better_auth_core::observability::logger::current().warn(
244                            "SIWE email reservation failed; using wallet placeholder email",
245                            &[better_auth_core::observability::LogArgument::Error(&cause)],
246                        );
247                        false
248                    }
249                };
250                if reserved {
251                    email_claim = Some(identifier);
252                    if ctx.database.get_user_by_email(email).await?.is_none() {
253                        user_email = email.clone();
254                    }
255                }
256            }
257            let profile = match &self.ens_lookup {
258                Some(lookup) => lookup(address.clone()).await?,
259                None => EnsProfile::default(),
260            };
261            let mut create = CreateUser::new()
262                .with_email(&user_email)
263                .with_name(profile.name.unwrap_or_else(|| address.clone()));
264            create.image = Some(profile.avatar.unwrap_or_default()).into();
265
266            let endpoint = super::endpoint_context::EndpointContext::new(
267                Some(req),
268                req.input_field_value()?,
269                ctx,
270            );
271            let created =
272                super::user_admission::create_user(create.clone(), "siwe", &endpoint).await;
273            let created = match created {
274                Err(error) if supplied_email.as_deref() == Some(user_email.as_str()) => {
275                    if ctx.database.get_user_by_email(&user_email).await?.is_some() {
276                        create.email = Some(wallet_email);
277                        super::user_admission::create_user(create, "siwe", &endpoint).await
278                    } else {
279                        Err(error)
280                    }
281                }
282                result => result,
283            };
284            if let Some(identifier) = email_claim
285                && let Err(cause) = ctx
286                    .database
287                    .consume_verification_by_identifier(&identifier)
288                    .await
289            {
290                better_auth_core::observability::logger::current().warn(
291                    "SIWE email reservation cleanup failed",
292                    &[better_auth_core::observability::LogArgument::Error(&cause)],
293                );
294            }
295            created?
296        };
297        if new_user || exact_wallet.is_none() {
298            let _ = ctx
299                .database
300                .create_wallet_address(CreateWalletAddress {
301                    additional_fields: Default::default(),
302                    user_id: user.id().typed()?.to_string(),
303                    address: address.clone(),
304                    chain_id,
305                    is_primary: new_user,
306                    created_at: Utc::now().into(),
307                })
308                .await?;
309            let _ = ctx
310                .database
311                .create_account(CreateAccount {
312                    user_id: user.id().into_owned(),
313                    provider_id: "siwe".into(),
314                    account_id: (format!("{address}:{chain_id}")).into(),
315                    access_token: Default::default(),
316                    refresh_token: Default::default(),
317                    id_token: Default::default(),
318                    access_token_expires_at: Default::default(),
319                    refresh_token_expires_at: Default::default(),
320                    scope: Default::default(),
321                    password: Default::default(),
322                    ..Default::default()
323                })
324                .await?;
325        }
326        let meta = RequestMeta::from_request_with_config(req, &ctx.config.advanced.ip_address);
327        let issued = issue_user_session(ctx, user.id().typed()?, meta.ip_address, meta.user_agent)
328            .await
329            .map_err(SessionIssueError::into_auth_error)?;
330        let manager = ctx.session_manager();
331        manager
332            .set_session_cookie(
333                req,
334                manager.internal_data(&issued.user, &issued.session).await?,
335                None,
336            )
337            .await?;
338        Ok(AuthResponse::json(
339            200,
340            &json!({"token":issued.session.token(),"success":true,"user":{"id":user.id(),"walletAddress":address,"chainId":chain_id}}),
341        )?)
342    }
343}
344
345fn valid_nonce(nonce: &str) -> bool {
346    (8..=250).contains(&nonce.len()) && nonce.bytes().all(|byte| byte.is_ascii_alphanumeric())
347}
348fn normalize_domain(domain: &str) -> String {
349    let value = domain.trim().to_lowercase();
350    let value = value
351        .split_once("://")
352        .map_or(value.as_str(), |(_, rest)| rest);
353    value.split('/').next().unwrap_or_default().to_owned()
354}
355fn parse_date(date: &str) -> Option<DateTime<Utc>> {
356    DateTime::parse_from_rfc3339(date)
357        .or_else(|_| DateTime::parse_from_rfc2822(date))
358        .ok()
359        .map(|date| date.with_timezone(&Utc))
360        .or_else(|| {
361            NaiveDate::parse_from_str(date, "%Y-%m-%d")
362                .ok()?
363                .and_hms_opt(0, 0, 0)
364                .map(|date| date.and_utc())
365        })
366}
367fn mismatch() -> AuthResult<AuthResponse> {
368    unauthorized(
369        "Unauthorized: SIWE message does not match the expected nonce, domain, address, or chain ID",
370        Some("UNAUTHORIZED_SIWE_MESSAGE_MISMATCH"),
371    )
372}
373fn unauthorized(message: &str, code: Option<&str>) -> AuthResult<AuthResponse> {
374    let mut body = serde_json::Map::from_iter([
375        (String::from("message"), json!(message)),
376        (String::from("status"), json!(401)),
377    ]);
378    if let Some(code) = code {
379        let _ = body.insert("code".into(), json!(code));
380    }
381    Ok(AuthResponse::json(401, &body)?)
382}
383
384/// Convert a valid Ethereum address to its EIP-55 checksum representation.
385fn checksum_address(address: &str) -> Option<String> {
386    let address = address.strip_prefix("0x")?;
387    if address.len() != 40 || !address.bytes().all(|byte| byte.is_ascii_hexdigit()) {
388        return None;
389    }
390    let lowercase = address.to_ascii_lowercase();
391    let digest = Keccak256::digest(lowercase.as_bytes());
392    let nibbles = digest.iter().flat_map(|byte| [byte >> 4, byte & 15]);
393    let mut result = String::from("0x");
394    for (byte, nibble) in lowercase.bytes().zip(nibbles) {
395        result.push(char::from(if nibble >= 8 {
396            byte.to_ascii_uppercase()
397        } else {
398            byte
399        }));
400    }
401    Some(result)
402}
403
404#[derive(Default)]
405struct ParsedMessage<'a> {
406    domain: Option<&'a str>,
407    address: Option<&'a str>,
408    chain_id: Option<i64>,
409    nonce: Option<&'a str>,
410    expiration_time: Option<&'a str>,
411    not_before: Option<&'a str>,
412}
413impl<'a> ParsedMessage<'a> {
414    fn parse(message: &'a str) -> Self {
415        let mut parsed = Self::default();
416        let mut lines = message.lines();
417        parsed.domain = lines
418            .next()
419            .and_then(|line| line.strip_suffix(" wants you to sign in with your Ethereum account:"))
420            .and_then(|header| {
421                let domain = header
422                    .split_once("://")
423                    .map_or(header, |(_, domain)| domain);
424                (!domain.is_empty() && !domain.chars().any(char::is_whitespace)).then_some(domain)
425            });
426        parsed.address = lines.next().map(str::trim);
427        for line in message.lines() {
428            if let Some((key, value)) = line.split_once(": ") {
429                match key {
430                    "Chain ID" => {
431                        parsed.chain_id = value
432                            .trim()
433                            .parse::<f64>()
434                            .ok()
435                            .filter(|value| {
436                                value.fract() == 0.0
437                                    && *value < i64::MAX as f64
438                                    && *value >= i64::MIN as f64
439                            })
440                            .map(|value| value as i64)
441                    }
442                    "Nonce" => parsed.nonce = Some(value),
443                    "Expiration Time" => parsed.expiration_time = Some(value),
444                    "Not Before" => parsed.not_before = Some(value),
445                    _ => {}
446                }
447            }
448        }
449        parsed
450    }
451}
452
453#[async_trait::async_trait]
454impl<S: AuthSchema> better_auth_core::AuthPlugin<S> for SiwePlugin {
455    fn name(&self) -> &'static str {
456        "siwe"
457    }
458    fn routes(&self) -> Vec<better_auth_core::AuthRoute> {
459        let anonymous = self.anonymous;
460        vec![
461            better_auth_core::AuthRoute::post("/siwe/nonce", "getSiweNonce")
462                .body_validator(request::nonce),
463            better_auth_core::AuthRoute::post("/siwe/get-nonce", "getNonce")
464                .body_validator(request::nonce),
465            better_auth_core::AuthRoute::post("/siwe/verify", "verifySiweMessage")
466                .body_validator(move |req| request::verify(req, anonymous)),
467        ]
468    }
469    async fn on_request(
470        &self,
471        req: &AuthRequest,
472        ctx: &AuthContext<S>,
473    ) -> AuthResult<Option<AuthResponse>> {
474        if req.method != better_auth_core::HttpMethod::Post {
475            return Ok(None);
476        }
477        match req.path() {
478            "/siwe/nonce" | "/siwe/get-nonce" => self.nonce(req, ctx).await.map(Some),
479            "/siwe/verify" => self.verify(req, ctx).await.map(Some),
480            _ => Ok(None),
481        }
482    }
483}
484
485fn unknown_keys(body: &Value, allowed: &[&str]) -> Option<String> {
486    let keys: Vec<_> = body
487        .as_object()?
488        .keys()
489        .filter(|key| !allowed.contains(&key.as_str()))
490        .map(|key| format!("\"{key}\""))
491        .collect();
492    if keys.is_empty() {
493        None
494    } else {
495        Some(format!(
496            "[body] Unrecognized {}: {}",
497            if keys.len() == 1 { "key" } else { "keys" },
498            keys.join(", ")
499        ))
500    }
501}