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#[derive(Debug, Clone)]
22pub struct SiweVerification {
23 pub message: String,
25 pub signature: String,
27 pub address: String,
29 pub chain_id: i64,
31 pub cacao: Value,
33}
34#[derive(Debug, Clone, Default)]
36pub struct EnsProfile {
37 pub name: Option<String>,
39 pub avatar: Option<String>,
41}
42
43#[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 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 pub fn anonymous(mut self, anonymous: bool) -> Self {
73 self.anonymous = anonymous;
74 self
75 }
76 pub fn email_domain_name(mut self, domain: impl Into<String>) -> Self {
78 self.email_domain_name = Some(domain.into());
79 self
80 }
81 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
384fn 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}