1mod config;
5
6mod crypto;
7
8use alibi_core::utils::datetime as date;
9
10mod parse;
11
12mod validation;
13
14use super::helpers::{apply_default_role, issue_selected_user_session_record};
15use alibi_core::{
16 AuthContext, AuthError, AuthRequest, AuthResponse, AuthResult, AuthSchema, AuthSession,
17 AuthUser, CreateAccount, CreateUser, CreateVerification, CreateWalletAddress, RequestMeta,
18};
19use chrono::{Duration, Utc};
20pub use config::{
21 Cacao, CacaoHeader, CacaoPayload, CacaoSignature, EnsLookup, EnsProfile, RandomSiweNonce,
22 SiweCallbackError, SiweCallbackResult, SiweConfig, SiweNonceProvider, SiweVerification,
23 SiweVerifier,
24};
25pub use crypto::{Eip191Verifier, ethereum_message_hash};
26use serde_json::json;
27use validation::VerifyBody;
28
29#[derive(Debug, Clone)]
30pub struct SiwePlugin {
31 config: SiweConfig,
32}
33
34impl SiwePlugin {
35 #[must_use]
36 pub const fn new(config: SiweConfig) -> Self {
37 Self { config }
38 }
39
40 async fn handle_nonce<S: AuthSchema>(
41 &self,
42 request: &AuthRequest,
43 ctx: &AuthContext<S>,
44 ) -> AuthResult<AuthResponse> {
45 if let Err(response) = validation::nonce_body(request) {
46 return Ok(response);
47 }
48 let nonce = match self.config.nonce_provider.get_nonce().await {
49 Ok(nonce) => nonce,
50 Err(SiweCallbackError::Api(response)) => return Ok(response),
51 Err(error) => return Err(AuthError::internal(error.to_string())),
52 };
53 if !parse::valid_nonce(&nonce) {
54 return Ok(AuthResponse::json(
55 500,
56 &json!({
57 "message":"SIWE getNonce must return an ERC-4361 nonce: 8-250 alphanumeric characters.",
58 "status":500,"code":"SIWE_INVALID_NONCE"
59 }),
60 )?);
61 }
62 drop(
63 ctx.verifications()
64 .create(CreateVerification {
65 identifier: format!("siwe:{nonce}"),
66 value: nonce.clone(),
67 expires_at: Utc::now() + Duration::seconds(900),
68 })
69 .await?,
70 );
71 Ok(AuthResponse::json(200, &json!({"nonce":nonce}))?)
72 }
73
74 async fn handle_verify<S: AuthSchema>(
75 &self,
76 request: &AuthRequest,
77 ctx: &AuthContext<S>,
78 ) -> AuthResult<AuthResponse> {
79 let body = match validation::verify_body(request, self.config.anonymous) {
80 Ok(body) => body,
81 Err(response) => return Ok(response),
82 };
83 match self.verify(request, ctx, body).await {
84 Ok(response) | Err(SiweCallbackError::Api(response)) => Ok(response),
85 Err(error) => Ok(AuthResponse::json(
86 401,
87 &json!({
88 "message":"Something went wrong. Please try again later.", "error":error.to_string(), "status":401
89 }),
90 )?),
91 }
92 }
93
94 #[expect(
95 clippy::too_many_lines,
96 reason = "Keep proof validation, identity updates, and session callbacks in their protocol order"
97 )]
98 async fn verify<S: AuthSchema>(
99 &self,
100 request: &AuthRequest,
101 ctx: &AuthContext<S>,
102 body: VerifyBody,
103 ) -> SiweCallbackResult<AuthResponse> {
104 let parsed = parse::parse_message(&body.message);
105 let nonce = parsed
106 .nonce
107 .filter(|nonce| parse::valid_nonce(nonce))
108 .ok_or_else(mismatch)?;
109 let consumed = ctx
110 .verifications()
111 .consume(&format!("siwe:{nonce}"))
112 .await
113 .map_err(storage_error)?;
114 if consumed.is_none() {
115 return Err(endpoint_error(
116 "Unauthorized: Invalid or expired nonce",
117 "UNAUTHORIZED_INVALID_OR_EXPIRED_NONCE",
118 ));
119 }
120 let address = parsed
121 .address
122 .and_then(crypto::checksum_address)
123 .ok_or_else(mismatch)?;
124 let chain_id = parsed
125 .chain_id
126 .filter(|chain| *chain > 0.0)
127 .ok_or_else(mismatch)?;
128 if parsed.domain.is_none_or(|domain| {
129 parse::normalize_domain(domain) != parse::normalize_domain(&self.config.domain)
130 }) {
131 return Err(mismatch());
132 }
133 let now = Utc::now().timestamp_millis();
134 if parsed
135 .expiration_time
136 .and_then(date::parse_date_millis)
137 .is_some_and(|expiry| now >= expiry)
138 {
139 return Err(endpoint_error(
140 "Unauthorized: SIWE message has expired",
141 "UNAUTHORIZED_SIWE_MESSAGE_EXPIRED",
142 ));
143 }
144 if parsed
145 .not_before
146 .and_then(date::parse_date_millis)
147 .is_some_and(|start| now < start)
148 {
149 return Err(endpoint_error(
150 "Unauthorized: SIWE message is not yet valid",
151 "UNAUTHORIZED_SIWE_MESSAGE_NOT_YET_VALID",
152 ));
153 }
154 let cacao = Cacao {
155 h: CacaoHeader {
156 t: "caip122".to_owned(),
157 },
158 p: CacaoPayload {
159 domain: self.config.domain.clone(),
160 aud: self.config.domain.clone(),
161 nonce: nonce.to_owned(),
162 iss: self.config.domain.clone(),
163 version: "1".to_owned(),
164 },
165 s: CacaoSignature {
166 t: "eip191".to_owned(),
167 s: body.signature.clone(),
168 },
169 };
170 let verified = self
171 .config
172 .verifier
173 .verify_message(SiweVerification {
174 message: body.message.clone(),
175 signature: body.signature,
176 address: address.clone(),
177 chain_id,
178 cacao,
179 })
180 .await?;
181 if !verified {
182 return Err(SiweCallbackError::Api(
183 AuthResponse::json(
184 401,
185 &json!({"message":"Unauthorized: Invalid SIWE signature","status":401}),
186 )
187 .map_err(|error| storage_error(error.into()))?,
188 ));
189 }
190 let exact_wallet = ctx
191 .database
192 .get_wallet_address(&address, Some(chain_id))
193 .await
194 .map_err(storage_error)?;
195 let wallet = if exact_wallet.is_some() {
196 exact_wallet.clone()
197 } else {
198 ctx.database
199 .get_wallet_address(&address, None)
200 .await
201 .map_err(storage_error)?
202 };
203 let user = if let Some(wallet) = wallet {
204 ctx.database
205 .get_user_by_id_record(&wallet.user_id)
206 .await
207 .map_err(storage_error)?
208 } else {
209 None
210 };
211 let user = if let Some(user) = user {
212 if exact_wallet.is_none() {
213 self.link_wallet(ctx, &user.id(), &address, chain_id, false)
214 .await?;
215 }
216 user
217 } else {
218 let user_2 = self
219 .create_user(ctx, &address, body.email.as_deref())
220 .await?;
221 self.link_wallet(ctx, &user_2.id(), &address, chain_id, true)
222 .await?;
223 user_2
224 };
225 let meta = RequestMeta::from_request(request);
226 let issued =
227 issue_selected_user_session_record(ctx, user, meta.ip_address, meta.user_agent)
228 .await
229 .map_err(|error| storage_error(error.into_auth_error()))?;
230 let token = issued.session.token();
231 let mut response = AuthResponse::json(200, &json!({"token":token,"success":true,"user":{"id":issued.user.id(),"walletAddress":address,"chainId":chain_id}})).map_err(|error| storage_error(error.into()))?;
232 Self::session_cookies(request, ctx, token, &mut response).map_err(|error| match error {
233 AuthError::CallbackFailure(cause) => match *cause {
236 AuthError::Internal(message) => SiweCallbackError::Failed(message),
237 other => storage_error(other),
238 },
239 other => storage_error(other),
240 })?;
241 Ok(response)
242 }
243
244 async fn create_user<S: AuthSchema>(
245 &self,
246 ctx: &AuthContext<S>,
247 address: &str,
248 email: Option<&str>,
249 ) -> SiweCallbackResult<alibi_core::AdapterRecord<S::User>> {
250 let normalized_email = email.map(str::to_lowercase);
251 let wallet_email = self
252 .config
253 .email_domain_name
254 .as_deref()
255 .filter(|domain| !domain.is_empty())
256 .map_or_else(
257 || format!("{address}@siwe.placeholder.invalid"),
258 |domain| format!("{address}@{domain}"),
259 )
260 .to_lowercase();
261 let mut user_email = wallet_email.clone();
262 let mut claim = None;
263 if !self.config.anonymous
264 && let Some(email_2) = &normalized_email
265 {
266 let identifier = format!("siwe-email-claim-{email_2}");
267 let reserved = ctx
268 .verifications()
269 .reserve(CreateVerification {
270 identifier: identifier.clone(),
271 value: address.to_owned(),
272 expires_at: Utc::now() + Duration::seconds(60),
273 })
274 .await
275 .unwrap_or(false);
276 if reserved {
277 claim = Some(identifier);
278 if ctx
279 .database
280 .get_user_by_email_record(email_2)
281 .await
282 .map_err(storage_error)?
283 .is_none()
284 {
285 user_email = email_2.clone();
286 }
287 }
288 }
289 let profile = match &self.config.ens_lookup {
292 Some(lookup) => lookup.lookup(address).await?,
293 None => EnsProfile::default(),
294 };
295 let mut create = CreateUser::new()
296 .with_email(&user_email)
297 .with_name(profile.name.unwrap_or_else(|| address.to_owned()));
298 create.image = Some(profile.avatar.unwrap_or_default());
299 apply_default_role(ctx, &mut create);
300 let created = ctx
301 .database
302 .create_user_with_source_record(
303 create.clone(),
304 alibi_core::user_validation::UserValidationSource::creation("siwe"),
305 )
306 .await;
307 let created = match created {
308 Ok(user) => Ok(user),
309 Err(error) if Some(&user_email) == normalized_email.as_ref() => {
310 let email_3 = normalized_email.as_deref().unwrap_or_default();
311 match ctx.database.get_user_by_email_record(email_3).await {
312 Ok(Some(_)) => {
313 create.email = Some(wallet_email);
314 ctx.database
315 .create_user_with_source_record(
316 create,
317 alibi_core::user_validation::UserValidationSource::creation("siwe"),
318 )
319 .await
320 .map_err(storage_error)
321 }
322 Ok(None) => Err(storage_error(error)),
323 Err(error_2) => Err(storage_error(error_2)),
324 }
325 }
326 Err(error) => Err(storage_error(error)),
327 };
328 if let Some(identifier) = claim {
329 drop(ctx.verifications().consume(&identifier).await);
330 }
331 created
332 }
333
334 async fn link_wallet<S: AuthSchema>(
335 &self,
336 ctx: &AuthContext<S>,
337 user_id: &str,
338 address: &str,
339 chain_id: f64,
340 primary: bool,
341 ) -> SiweCallbackResult<()> {
342 drop(
343 ctx.database
344 .create_wallet_address(CreateWalletAddress {
345 user_id: user_id.to_owned(),
346 address: address.to_owned(),
347 chain_id,
348 is_primary: primary,
349 })
350 .await
351 .map_err(storage_error)?,
352 );
353 let mut number = ryu_js::Buffer::new();
354 drop(
355 ctx.database
356 .create_account_record(CreateAccount {
357 additional_fields: Default::default(),
358 user_id: user_id.to_owned(),
359 account_id: format!("{address}:{}", number.format(chain_id)),
360 provider_id: "siwe".to_owned(),
361 access_token: None,
362 refresh_token: None,
363 id_token: None,
364 access_token_expires_at: None,
365 refresh_token_expires_at: None,
366 scope: None,
367 password: None,
368 })
369 .await
370 .map_err(storage_error)?,
371 );
372 Ok(())
373 }
374
375 fn session_cookies<S: AuthSchema>(
376 request: &AuthRequest,
377 ctx: &AuthContext<S>,
378 token: &str,
379 response: &mut AuthResponse,
380 ) -> AuthResult<()> {
381 use alibi_core::utils::cookie_utils::{
382 create_session_cookie_with_max_age, create_session_like_cookie, related_cookie_name,
383 sign_cookie_value, verify_cookie_value,
384 };
385 let name = related_cookie_name(&ctx.config, "dont_remember");
386 let preference = super::helpers::get_cookie(request, &name)
387 .and_then(|value| verify_cookie_value(&value, ctx.config.current_secret()))
388 .is_some_and(|value| !value.is_empty());
389 response.headers.append(
390 "Set-Cookie",
391 create_session_cookie_with_max_age(
392 Some(token),
393 (!preference).then(|| ctx.config.session.expires_in.num_seconds()),
394 &ctx.config,
395 )?,
396 );
397 if preference {
398 response.headers.append(
399 "Set-Cookie",
400 create_session_like_cookie(
401 &name,
402 &sign_cookie_value("true", ctx.config.current_secret()),
403 None,
404 &ctx.config,
405 )?,
406 );
407 }
408 Ok(())
409 }
410}
411
412alibi_core::impl_auth_plugin! {
413 SiwePlugin, "siwe";
414 routes {
415 post "/siwe/nonce" => handle_nonce, "get_siwe_nonce";
416 post "/siwe/get-nonce" => handle_nonce, "get_nonce";
417 post "/siwe/verify" => handle_verify, "verify_siwe_message";
418 }
419
420 extra {
421 fn static_openapi_metadata(&self) -> alibi_core::PluginOpenApiMetadata {
422 crate::metadata::plugin_metadata(<Self as alibi_core::AuthPlugin<S>>::name(self), &<Self as alibi_core::AuthPlugin<S>>::routes(self))
423 }
424
425 fn openapi_metadata(&self, ctx: &alibi_core::AuthInitContext<S>) -> alibi_core::PluginOpenApiMetadata {
426 crate::metadata::instance_plugin_metadata(<Self as alibi_core::AuthPlugin<S>>::name(self), &<Self as alibi_core::AuthPlugin<S>>::routes(self), ctx)
427 }
428 }
429}
430
431fn mismatch() -> SiweCallbackError {
432 endpoint_error(
433 "Unauthorized: SIWE message does not match the expected nonce, domain, address, or chain ID",
434 "UNAUTHORIZED_SIWE_MESSAGE_MISMATCH",
435 )
436}
437
438fn endpoint_error(message: &str, code: &str) -> SiweCallbackError {
439 match AuthResponse::json(401, &json!({"message":message,"status":401,"code":code})) {
440 Ok(response) => SiweCallbackError::Api(response),
441 Err(error) => SiweCallbackError::Failed(error.to_string()),
442 }
443}
444
445fn storage_error(error: AuthError) -> SiweCallbackError {
446 if error.status_code() < 500 || matches!(error, AuthError::Upstream { .. }) {
447 SiweCallbackError::Api(error.to_auth_response())
448 } else {
449 SiweCallbackError::Failed(error.to_string())
450 }
451}
452
453#[cfg(test)]
455mod tests {
456 #![allow(
457 clippy::unwrap_used,
458 clippy::expect_used,
459 clippy::indexing_slicing,
460 reason = "protocol tests assert concrete persisted and signed payloads"
461 )]
462
463 use super::*;
464 use alibi_core::{AuthAccount, AuthPlugin, AuthVerification, HttpMethod};
465 use alibi_seaorm::sea_orm::{EntityTrait, PaginatorTrait};
466 use alibi_seaorm::store::entities::wallet_address;
467 use alibi_seaorm::{Database, DatabaseConnection, SeaOrmStore};
468 use async_trait::async_trait;
469 use k256::ecdsa::SigningKey;
470 use std::fmt::Write;
471 use std::sync::{
472 Arc,
473 atomic::{AtomicU32, Ordering},
474 };
475 use tokio::sync::Mutex;
476
477 type TestSchema = alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema;
478 const ADDRESS: &str = "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf";
479
480 #[derive(Default)]
481 struct Nonces(AtomicU32);
482 #[async_trait]
483 impl SiweNonceProvider for Nonces {
484 async fn get_nonce(&self) -> SiweCallbackResult<String> {
485 Ok(format!(
486 "FixtureNonce{:08}",
487 self.0.fetch_add(1, Ordering::SeqCst)
488 ))
489 }
490 }
491 struct FixedNonce(&'static str);
492 #[async_trait]
493 impl SiweNonceProvider for FixedNonce {
494 async fn get_nonce(&self) -> SiweCallbackResult<String> {
495 Ok(self.0.to_owned())
496 }
497 }
498
499 #[derive(Default)]
500 struct Verifier {
501 inputs: Mutex<Vec<SiweVerification>>,
502 }
503 #[async_trait]
504 impl SiweVerifier for Verifier {
505 async fn verify_message(&self, input: SiweVerification) -> SiweCallbackResult<bool> {
506 self.inputs.lock().await.push(input.clone());
507 Eip191Verifier.verify_message(input).await
508 }
509 }
510
511 async fn context() -> (AuthContext<TestSchema>, DatabaseConnection) {
512 let database = Database::connect("sqlite::memory:").await.unwrap();
513 alibi_seaorm::store::__private_test_support::migrator::run_migrations(&database)
514 .await
515 .unwrap();
516 let config = Arc::new(alibi_core::AuthConfig::new(
517 "siwe-native-context-secret-at-least-32-characters",
518 ));
519 let store = Arc::new(SeaOrmStore::<TestSchema>::new(
520 Arc::clone(&config),
521 database.clone(),
522 ));
523 (AuthContext::new(config, store), database)
524 }
525
526 fn plugin() -> (SiwePlugin, Arc<Verifier>) {
527 let verifier = Arc::new(Verifier::default());
528 let config = SiweConfig::new(
529 "fixture.example",
530 Arc::new(Nonces::default()),
531 Arc::<Verifier>::clone(&verifier),
532 );
533 (SiwePlugin::new(config), verifier)
534 }
535
536 fn request(path: &str, body: &serde_json::Value) -> AuthRequest {
537 let mut request = AuthRequest::new(HttpMethod::Post, path);
538 request.body = Some(serde_json::to_vec(&body).unwrap());
539 drop(
540 request
541 .headers
542 .insert("content-type".into(), "application/json".into()),
543 );
544 request
545 }
546 fn body(response: &AuthResponse) -> serde_json::Value {
547 serde_json::from_slice(&response.body).unwrap()
548 }
549
550 async fn issue(plugin: &SiwePlugin, ctx: &AuthContext<TestSchema>, path: &str) -> String {
551 let response = plugin
552 .on_request(&request(path, &(json!({}))), ctx)
553 .await
554 .unwrap()
555 .unwrap();
556 assert_eq!(response.status, 200, "{}", body(&response));
557 body(&response)["nonce"].as_str().unwrap().to_owned()
558 }
559
560 fn message(nonce: &str, domain: &str, address: &str, chain: &str, extra: &str) -> String {
561 format!(
562 "{domain} wants you to sign in with your Ethereum account:\n{address}\n\nSign in — Ελληνικά\n\nURI: not-a-url\nVersion: 999\nChain ID: {chain}\nNonce: {nonce}\nIssued At: invalid date\n{extra}"
563 )
564 }
565
566 fn sign(message: &str, scalar: u8) -> String {
567 use sha3::{Digest, Keccak256};
568 let mut secret = [0u8; 32];
569 secret[31] = scalar;
570 let key = SigningKey::from_slice(&secret).unwrap();
571 let prefix = format!("\x19Ethereum Signed Message:\n{}", message.len());
572 let mut digest = Keccak256::new();
573 digest.update(prefix.as_bytes());
574 digest.update(message.as_bytes());
575 let (signature, recovery) = key.sign_prehash_recoverable(&digest.finalize());
576 let mut bytes = signature.to_bytes().to_vec();
577 bytes.push(recovery.to_byte() + 27);
578 format!(
579 "0x{}",
580 bytes.iter().fold(String::new(), |mut output, byte| {
581 _ = write!(output, "{byte:02x}");
582 output
583 })
584 )
585 }
586
587 async fn verify(
588 plugin: &SiwePlugin,
589 ctx: &AuthContext<TestSchema>,
590 message: &str,
591 scalar: u8,
592 email: Option<&str>,
593 ) -> AuthResponse {
594 let mut body = json!({"message":message,"signature":sign(message, scalar)});
595 if let Some(email) = email {
596 body["email"] = json!(email);
597 }
598 plugin
599 .on_request(&request("/siwe/verify", &(body)), ctx)
600 .await
601 .unwrap()
602 .unwrap()
603 }
604
605 #[tokio::test]
606 #[expect(
607 clippy::too_many_lines,
608 reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
609 )]
610 async fn independent_unicode_signature_creates_an_authenticated_wallet_identity_and_rotates_only_sessions()
611 {
612 let (ctx, database) = context().await;
613 let verifier = Arc::new(Verifier::default());
614 let plugin = SiwePlugin::new(SiweConfig::new(
615 "fixture.example",
616 Arc::new(FixedNonce("GoldenNonce0001")),
617 Arc::<Verifier>::clone(&verifier),
618 ));
619 let fixture: serde_json::Value = serde_json::from_str(include_str!(
620 "../../../../../tests/fixtures/siwe/eip191-noble-2.0.1.json"
621 ))
622 .unwrap();
623 let nonce = issue(&plugin, &ctx, "/siwe/get-nonce").await;
624 let proof = ctx
625 .database
626 .get_latest_verification_by_identifier(&format!("siwe:{nonce}"))
627 .await
628 .unwrap()
629 .unwrap();
630 assert_eq!(proof.value(), nonce);
631 assert!(
632 ((proof.expires_at() - proof.created_at()).num_milliseconds() - 900_000).abs() < 1_000
633 );
634 let response = plugin.on_request(&request("/siwe/verify", &(json!({"message":fixture["message"],"signature":fixture["signature"],"email":"ignored@fixture.test"}))), &ctx).await.unwrap().unwrap();
635 assert_eq!(response.status, 200, "{}", body(&response));
636 let result = body(&response);
637 assert_eq!(result.as_object().unwrap().len(), 3);
638 assert_eq!(result["success"], true);
639 assert_eq!(result["user"]["walletAddress"], ADDRESS);
640 assert_eq!(result["user"]["chainId"].as_f64(), Some(1.0));
641 let user_id = result["user"]["id"].as_str().unwrap();
642 let user = ctx.database.get_user_by_id(user_id).await.unwrap().unwrap();
643 assert_eq!(
644 user.email.as_deref(),
645 Some("0x7e5f4552091a69125d5dfcb7b8c2659029395bdf@siwe.placeholder.invalid")
646 );
647 assert_eq!(user.name.as_deref(), Some(ADDRESS));
648 assert_eq!(user.image.as_deref(), Some(""));
649 assert!(!user.email_verified);
650 let wallet = ctx
651 .database
652 .get_wallet_address(ADDRESS, Some(1.0))
653 .await
654 .unwrap()
655 .unwrap();
656 assert_eq!(wallet.user_id, user_id);
657 assert!(wallet.is_primary);
658 let accounts = ctx.database.get_user_accounts(user_id).await.unwrap();
659 assert_eq!(accounts.len(), 1);
660 assert_eq!(accounts[0].provider_id(), "siwe");
661 assert_eq!(accounts[0].account_id(), format!("{ADDRESS}:1"));
662 let cookie = response
663 .headers
664 .get_all("Set-Cookie")
665 .find(|header| header.starts_with("better-auth.session_token="))
666 .unwrap()
667 .split(';')
668 .next()
669 .unwrap();
670 let mut read = AuthRequest::new(HttpMethod::Get, "/get-session");
671 drop(read.headers.insert("cookie".into(), cookie.to_owned()));
672 let (_, session) = ctx.require_session(&read).await.unwrap();
673 assert_eq!(session.user_id, user_id);
674 assert_eq!(session.token, result["token"].as_str().unwrap());
675 assert!(
676 ctx.database
677 .get_latest_verification_by_identifier(&format!("siwe:{nonce}"))
678 .await
679 .unwrap()
680 .is_none()
681 );
682 let replay = plugin
683 .on_request(
684 &request(
685 "/siwe/verify",
686 &(json!({"message":fixture["message"],"signature":fixture["signature"]})),
687 ),
688 &ctx,
689 )
690 .await
691 .unwrap()
692 .unwrap();
693 assert_eq!(
694 body(&replay)["code"],
695 "UNAUTHORIZED_INVALID_OR_EXPIRED_NONCE"
696 );
697 let next = issue(&plugin, &ctx, "/siwe/nonce").await;
698 let signed = message(
699 &next,
700 "HTTPS://FIXTURE.EXAMPLE/ignored",
701 &ADDRESS.to_lowercase(),
702 "0x10",
703 "Expiration Time: not a date\nNot Before: not a date",
704 );
705 let cross_chain = verify(&plugin, &ctx, &signed, 1, None).await;
706 assert_eq!(cross_chain.status, 200, "{}", body(&cross_chain));
707 assert_eq!(body(&cross_chain)["user"]["id"], user_id);
708 assert_ne!(body(&cross_chain)["token"], result["token"]);
709 assert!(
710 !ctx.database
711 .get_wallet_address(ADDRESS, Some(16.0))
712 .await
713 .unwrap()
714 .unwrap()
715 .is_primary
716 );
717 assert_eq!(
718 wallet_address::Entity::find()
719 .count(&database)
720 .await
721 .unwrap(),
722 2
723 );
724 assert_eq!(
725 ctx.database.get_user_sessions(user_id).await.unwrap().len(),
726 2
727 );
728 assert_eq!(
729 ctx.database.get_user_accounts(user_id).await.unwrap().len(),
730 2
731 );
732 let next_2 = issue(&plugin, &ctx, "/siwe/nonce").await;
733 let huge = verify(
734 &plugin,
735 &ctx,
736 &message(&next_2, "fixture.example", ADDRESS, "1e21", ""),
737 1,
738 None,
739 )
740 .await;
741 assert_eq!(huge.status, 200, "{}", body(&huge));
742 assert!(
743 ctx.database
744 .get_user_accounts(user_id)
745 .await
746 .unwrap()
747 .iter()
748 .any(|account| account.account_id() == format!("{ADDRESS}:1e+21"))
749 );
750 let observed = verifier.inputs.lock().await;
751 assert_eq!(observed.len(), 3);
752 assert_eq!(observed[0].cacao.p.nonce, "GoldenNonce0001");
753 assert_eq!(observed[0].cacao.p.domain, "fixture.example");
754 assert_eq!(observed[0].cacao.p.aud, "fixture.example");
755 assert_eq!(observed[0].cacao.p.iss, "fixture.example");
756 assert_eq!(
757 observed[0].cacao.s.s,
758 fixture["signature"].as_str().unwrap()
759 );
760 assert_eq!(
761 observed
762 .get(1)
763 .expect("callback receives the second fixture")
764 .chain_id
765 .to_bits(),
766 16.0_f64.to_bits()
767 );
768 assert_eq!(
769 observed
770 .get(2)
771 .expect("third observed proof")
772 .chain_id
773 .to_bits(),
774 1e21_f64.to_bits()
775 );
776 }
777
778 #[tokio::test]
779 async fn signed_preferences_change_cookie_persistence_without_shortening_siwe_sessions() {
780 use alibi_core::utils::cookie_utils::{related_cookie_name, sign_cookie_value};
781 let (ctx, _) = context().await;
782 let (plugin, _) = plugin();
783 let preference_name = related_cookie_name(&ctx.config, "dont_remember");
784 for (preference, temporary) in [
785 (None, false),
786 (Some(""), false),
787 (Some("false"), true),
788 (Some("true"), true),
789 ] {
790 let nonce = issue(&plugin, &ctx, "/siwe/nonce").await;
791 let signed = message(&nonce, "fixture.example", ADDRESS, "1", "");
792 let mut req = request(
793 "/siwe/verify",
794 &(json!({"message":signed,"signature":sign(&signed,1)})),
795 );
796 if let Some(preference) = preference {
797 drop(req.headers.insert(
798 "cookie".into(),
799 format!(
800 "{preference_name}={}",
801 sign_cookie_value(preference, &ctx.config.secret)
802 ),
803 ));
804 }
805 let response = plugin.on_request(&req, &ctx).await.unwrap().unwrap();
806 assert_eq!(response.status, 200);
807 let result = body(&response);
808 let session = ctx
809 .database
810 .get_session(result["token"].as_str().unwrap())
811 .await
812 .unwrap()
813 .unwrap();
814 assert!(
815 ((session.expires_at - session.created_at) - ctx.config.session.expires_in)
816 .num_milliseconds()
817 .abs()
818 < 1_000
819 );
820 let cookie = response
821 .headers
822 .get_all("Set-Cookie")
823 .find(|cookie| cookie.starts_with("better-auth.session_token="))
824 .unwrap();
825 assert_eq!(
826 !cookie.contains("Max-Age="),
827 temporary,
828 "preference={preference:?}"
829 );
830 assert_eq!(
831 response
832 .headers
833 .get_all("Set-Cookie")
834 .any(|candidate| candidate.starts_with(&format!("{preference_name}="))),
835 temporary
836 );
837 }
838 assert_eq!(
839 ctx.database
840 .get_user_sessions(
841 &ctx.database
842 .get_wallet_address(ADDRESS, Some(1.0))
843 .await
844 .unwrap()
845 .unwrap()
846 .user_id
847 )
848 .await
849 .unwrap()
850 .len(),
851 4
852 );
853 }
854}
855