1mod config;
5
6mod crypto;
7
8use alibi_core::field_policy::FieldValues;
9use alibi_core::utils::datetime as date;
10
11mod parse;
12
13mod validation;
14
15use super::helpers::{apply_default_role, issue_selected_user_session_record};
16use alibi_core::{
17 AuthContext, AuthError, AuthRequest, AuthResponse, AuthResult, AuthSchema, AuthSession,
18 AuthUser, CreateAccount, CreateUser, CreateVerification, CreateWalletAddress, RequestMeta,
19};
20use chrono::{Duration, Utc};
21pub use config::{
22 Cacao, CacaoHeader, CacaoPayload, CacaoSignature, EnsLookup, EnsProfile, RandomSiweNonce,
23 SiweCallbackError, SiweCallbackResult, SiweConfig, SiweNonceProvider, SiweVerification,
24 SiweVerifier,
25};
26pub use crypto::{Eip191Verifier, ethereum_message_hash};
27use serde_json::json;
28use validation::VerifyBody;
29
30#[derive(Debug, Clone)]
31pub struct SiwePlugin {
32 config: SiweConfig,
33}
34
35impl SiwePlugin {
36 #[must_use]
37 pub const fn new(config: SiweConfig) -> Self {
38 Self { config }
39 }
40
41 async fn handle_nonce<S: AuthSchema>(
42 &self,
43 request: &AuthRequest,
44 ctx: &AuthContext<S>,
45 ) -> AuthResult<AuthResponse> {
46 if let Err(response) = validation::nonce_body(request) {
47 return Ok(response);
48 }
49 let nonce = match self.config.nonce_provider.get_nonce().await {
50 Ok(nonce) => nonce,
51 Err(SiweCallbackError::Api(response)) => return Ok(response),
52 Err(error) => return Err(AuthError::internal(error.to_string())),
53 };
54 if !parse::valid_nonce(&nonce) {
55 return Ok(AuthResponse::json(
56 500,
57 &json!({
58 "message":"SIWE getNonce must return an ERC-4361 nonce: 8-250 alphanumeric characters.",
59 "status":500,"code":"SIWE_INVALID_NONCE"
60 }),
61 )?);
62 }
63 _ = ctx
64 .verifications()
65 .create(CreateVerification {
66 identifier: format!("siwe:{nonce}"),
67 value: nonce.clone(),
68 expires_at: Utc::now() + Duration::seconds(900),
69 })
70 .await?;
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 _ = 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 _ = ctx
343 .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 let mut number = ryu_js::Buffer::new();
353 _ = ctx
354 .database
355 .create_account_record(CreateAccount {
356 additional_fields: FieldValues::default(),
357 user_id: user_id.to_owned(),
358 account_id: format!("{address}:{}", number.format(chain_id)),
359 provider_id: "siwe".to_owned(),
360 access_token: None,
361 refresh_token: None,
362 id_token: None,
363 access_token_expires_at: None,
364 refresh_token_expires_at: None,
365 scope: None,
366 password: None,
367 })
368 .await
369 .map_err(storage_error)?;
370 Ok(())
371 }
372
373 fn session_cookies<S: AuthSchema>(
374 request: &AuthRequest,
375 ctx: &AuthContext<S>,
376 token: &str,
377 response: &mut AuthResponse,
378 ) -> AuthResult<()> {
379 use alibi_core::utils::cookie_utils::{
380 create_session_cookie_with_max_age, create_session_like_cookie, related_cookie_name,
381 sign_cookie_value, verify_cookie_value,
382 };
383 let name = related_cookie_name(&ctx.config, "dont_remember");
384 let preference = super::helpers::get_cookie(request, &name)
385 .and_then(|value| verify_cookie_value(&value, ctx.config.current_secret()))
386 .is_some_and(|value| !value.is_empty());
387 response.headers.append(
388 "Set-Cookie",
389 create_session_cookie_with_max_age(
390 Some(token),
391 (!preference).then(|| ctx.config.session.expires_in.num_seconds()),
392 &ctx.config,
393 )?,
394 );
395 if preference {
396 response.headers.append(
397 "Set-Cookie",
398 create_session_like_cookie(
399 &name,
400 &sign_cookie_value("true", ctx.config.current_secret()),
401 None,
402 &ctx.config,
403 )?,
404 );
405 }
406 Ok(())
407 }
408}
409
410alibi_core::impl_auth_plugin! {
411 SiwePlugin, "siwe";
412 routes {
413 post "/siwe/nonce" => handle_nonce, "get_siwe_nonce";
414 post "/siwe/get-nonce" => handle_nonce, "get_nonce";
415 post "/siwe/verify" => handle_verify, "verify_siwe_message";
416 }
417
418 extra {
419 route_openapi_metadata!(S);
420 }
421}
422
423fn mismatch() -> SiweCallbackError {
424 endpoint_error(
425 "Unauthorized: SIWE message does not match the expected nonce, domain, address, or chain ID",
426 "UNAUTHORIZED_SIWE_MESSAGE_MISMATCH",
427 )
428}
429
430fn endpoint_error(message: &str, code: &str) -> SiweCallbackError {
431 match AuthResponse::json(401, &json!({"message":message,"status":401,"code":code})) {
432 Ok(response) => SiweCallbackError::Api(response),
433 Err(error) => SiweCallbackError::Failed(error.to_string()),
434 }
435}
436
437fn storage_error(error: AuthError) -> SiweCallbackError {
438 if error.status_code() < 500 || matches!(error, AuthError::Upstream { .. }) {
439 SiweCallbackError::Api(error.to_auth_response())
440 } else {
441 SiweCallbackError::Failed(error.to_string())
442 }
443}
444
445#[cfg(test)]
447mod tests {
448 #![allow(
449 clippy::unwrap_used,
450 clippy::expect_used,
451 clippy::indexing_slicing,
452 reason = "protocol tests assert concrete persisted and signed payloads"
453 )]
454
455 use super::*;
456 use alibi_core::{AuthAccount, AuthPlugin, AuthVerification, HttpMethod};
457 use alibi_seaorm::sea_orm::{EntityTrait, PaginatorTrait};
458 use alibi_seaorm::store::entities::wallet_address;
459 use alibi_seaorm::{Database, DatabaseConnection, SeaOrmStore};
460 use async_trait::async_trait;
461 use k256::ecdsa::SigningKey;
462 use std::fmt::Write;
463 use std::sync::{
464 Arc,
465 atomic::{AtomicU32, Ordering},
466 };
467 use tokio::sync::Mutex;
468
469 type TestSchema = alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema;
470 const ADDRESS: &str = "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf";
471
472 #[derive(Default)]
473 struct Nonces(AtomicU32);
474 #[async_trait]
475 impl SiweNonceProvider for Nonces {
476 async fn get_nonce(&self) -> SiweCallbackResult<String> {
477 Ok(format!(
478 "FixtureNonce{:08}",
479 self.0.fetch_add(1, Ordering::SeqCst)
480 ))
481 }
482 }
483 struct FixedNonce(&'static str);
484 #[async_trait]
485 impl SiweNonceProvider for FixedNonce {
486 async fn get_nonce(&self) -> SiweCallbackResult<String> {
487 Ok(self.0.to_owned())
488 }
489 }
490
491 #[derive(Default)]
492 struct Verifier {
493 inputs: Mutex<Vec<SiweVerification>>,
494 }
495 #[async_trait]
496 impl SiweVerifier for Verifier {
497 async fn verify_message(&self, input: SiweVerification) -> SiweCallbackResult<bool> {
498 self.inputs.lock().await.push(input.clone());
499 Eip191Verifier.verify_message(input).await
500 }
501 }
502
503 async fn context() -> (AuthContext<TestSchema>, DatabaseConnection) {
504 let database = Database::connect("sqlite::memory:").await.unwrap();
505 alibi_seaorm::store::__private_test_support::migrator::run_migrations(&database)
506 .await
507 .unwrap();
508 let config = Arc::new(alibi_core::AuthConfig::new(
509 "siwe-native-context-secret-at-least-32-characters",
510 ));
511 let store = Arc::new(SeaOrmStore::<TestSchema>::new(
512 Arc::clone(&config),
513 database.clone(),
514 ));
515 (AuthContext::new(config, store), database)
516 }
517
518 fn plugin() -> (SiwePlugin, Arc<Verifier>) {
519 let verifier = Arc::new(Verifier::default());
520 let config = SiweConfig::new(
521 "fixture.example",
522 Arc::new(Nonces::default()),
523 Arc::<Verifier>::clone(&verifier),
524 );
525 (SiwePlugin::new(config), verifier)
526 }
527
528 fn request(path: &str, body: &serde_json::Value) -> AuthRequest {
529 let mut request = AuthRequest::new(HttpMethod::Post, path);
530 request.body = Some(serde_json::to_vec(&body).unwrap());
531 _ = request
532 .headers
533 .insert("content-type".into(), "application/json".into());
534 request
535 }
536 fn body(response: &AuthResponse) -> serde_json::Value {
537 serde_json::from_slice(&response.body).unwrap()
538 }
539
540 async fn issue(plugin: &SiwePlugin, ctx: &AuthContext<TestSchema>, path: &str) -> String {
541 let response = plugin
542 .on_request(&request(path, &(json!({}))), ctx)
543 .await
544 .unwrap()
545 .unwrap();
546 assert_eq!(response.status, 200, "{}", body(&response));
547 body(&response)["nonce"].as_str().unwrap().to_owned()
548 }
549
550 fn message(nonce: &str, domain: &str, address: &str, chain: &str, extra: &str) -> String {
551 format!(
552 "{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}"
553 )
554 }
555
556 fn sign(message: &str, scalar: u8) -> String {
557 use sha3::{Digest, Keccak256};
558 let mut secret = [0u8; 32];
559 secret[31] = scalar;
560 let key = SigningKey::from_slice(&secret).unwrap();
561 let prefix = format!("\x19Ethereum Signed Message:\n{}", message.len());
562 let mut digest = Keccak256::new();
563 digest.update(prefix.as_bytes());
564 digest.update(message.as_bytes());
565 let (signature, recovery) = key.sign_prehash_recoverable(&digest.finalize());
566 let mut bytes = signature.to_bytes().to_vec();
567 bytes.push(recovery.to_byte() + 27);
568 format!(
569 "0x{}",
570 bytes.iter().fold(String::new(), |mut output, byte| {
571 _ = write!(output, "{byte:02x}");
572 output
573 })
574 )
575 }
576
577 async fn verify(
578 plugin: &SiwePlugin,
579 ctx: &AuthContext<TestSchema>,
580 message: &str,
581 scalar: u8,
582 email: Option<&str>,
583 ) -> AuthResponse {
584 let mut body = json!({"message":message,"signature":sign(message, scalar)});
585 if let Some(email) = email {
586 body["email"] = json!(email);
587 }
588 plugin
589 .on_request(&request("/siwe/verify", &(body)), ctx)
590 .await
591 .unwrap()
592 .unwrap()
593 }
594
595 #[tokio::test]
596 #[expect(
597 clippy::too_many_lines,
598 reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
599 )]
600 async fn independent_unicode_signature_creates_an_authenticated_wallet_identity_and_rotates_only_sessions()
601 {
602 let (ctx, database) = context().await;
603 let verifier = Arc::new(Verifier::default());
604 let plugin = SiwePlugin::new(SiweConfig::new(
605 "fixture.example",
606 Arc::new(FixedNonce("GoldenNonce0001")),
607 Arc::<Verifier>::clone(&verifier),
608 ));
609 let fixture: serde_json::Value = serde_json::from_str(include_str!(
610 "../../../../tests/fixtures/siwe/eip191-noble-2.0.1.json"
611 ))
612 .unwrap();
613 let nonce = issue(&plugin, &ctx, "/siwe/get-nonce").await;
614 let proof = ctx
615 .database
616 .get_latest_verification_by_identifier(&format!("siwe:{nonce}"))
617 .await
618 .unwrap()
619 .unwrap();
620 assert_eq!(proof.value(), nonce);
621 assert!(
622 ((proof.expires_at() - proof.created_at()).num_milliseconds() - 900_000).abs() < 1_000
623 );
624 let response = plugin.on_request(&request("/siwe/verify", &(json!({"message":fixture["message"],"signature":fixture["signature"],"email":"ignored@fixture.test"}))), &ctx).await.unwrap().unwrap();
625 assert_eq!(response.status, 200, "{}", body(&response));
626 let result = body(&response);
627 assert_eq!(result.as_object().unwrap().len(), 3);
628 assert_eq!(result["success"], true);
629 assert_eq!(result["user"]["walletAddress"], ADDRESS);
630 assert_eq!(result["user"]["chainId"].as_f64(), Some(1.0));
631 let user_id = result["user"]["id"].as_str().unwrap();
632 let user = ctx.database.get_user_by_id(user_id).await.unwrap().unwrap();
633 assert_eq!(
634 user.email.as_deref(),
635 Some("0x7e5f4552091a69125d5dfcb7b8c2659029395bdf@siwe.placeholder.invalid")
636 );
637 assert_eq!(user.name.as_deref(), Some(ADDRESS));
638 assert_eq!(user.image.as_deref(), Some(""));
639 assert!(!user.email_verified);
640 let wallet = ctx
641 .database
642 .get_wallet_address(ADDRESS, Some(1.0))
643 .await
644 .unwrap()
645 .unwrap();
646 assert_eq!(wallet.user_id, user_id);
647 assert!(wallet.is_primary);
648 let accounts = ctx.database.get_user_accounts(user_id).await.unwrap();
649 assert_eq!(accounts.len(), 1);
650 assert_eq!(accounts[0].provider_id(), "siwe");
651 assert_eq!(accounts[0].account_id(), format!("{ADDRESS}:1"));
652 let cookie = response
653 .headers
654 .get_all("Set-Cookie")
655 .find(|header| header.starts_with("better-auth.session_token="))
656 .unwrap()
657 .split(';')
658 .next()
659 .unwrap();
660 let mut read = AuthRequest::new(HttpMethod::Get, "/get-session");
661 _ = read.headers.insert("cookie".into(), cookie.to_owned());
662 let (_, session) = ctx.require_session(&read).await.unwrap();
663 assert_eq!(session.user_id, user_id);
664 assert_eq!(session.token, result["token"].as_str().unwrap());
665 assert!(
666 ctx.database
667 .get_latest_verification_by_identifier(&format!("siwe:{nonce}"))
668 .await
669 .unwrap()
670 .is_none()
671 );
672 let replay = plugin
673 .on_request(
674 &request(
675 "/siwe/verify",
676 &(json!({"message":fixture["message"],"signature":fixture["signature"]})),
677 ),
678 &ctx,
679 )
680 .await
681 .unwrap()
682 .unwrap();
683 assert_eq!(
684 body(&replay)["code"],
685 "UNAUTHORIZED_INVALID_OR_EXPIRED_NONCE"
686 );
687 let next = issue(&plugin, &ctx, "/siwe/nonce").await;
688 let signed = message(
689 &next,
690 "HTTPS://FIXTURE.EXAMPLE/ignored",
691 &ADDRESS.to_lowercase(),
692 "0x10",
693 "Expiration Time: not a date\nNot Before: not a date",
694 );
695 let cross_chain = verify(&plugin, &ctx, &signed, 1, None).await;
696 assert_eq!(cross_chain.status, 200, "{}", body(&cross_chain));
697 assert_eq!(body(&cross_chain)["user"]["id"], user_id);
698 assert_ne!(body(&cross_chain)["token"], result["token"]);
699 assert!(
700 !ctx.database
701 .get_wallet_address(ADDRESS, Some(16.0))
702 .await
703 .unwrap()
704 .unwrap()
705 .is_primary
706 );
707 assert_eq!(
708 wallet_address::Entity::find()
709 .count(&database)
710 .await
711 .unwrap(),
712 2
713 );
714 assert_eq!(
715 ctx.database.get_user_sessions(user_id).await.unwrap().len(),
716 2
717 );
718 assert_eq!(
719 ctx.database.get_user_accounts(user_id).await.unwrap().len(),
720 2
721 );
722 let next_2 = issue(&plugin, &ctx, "/siwe/nonce").await;
723 let huge = verify(
724 &plugin,
725 &ctx,
726 &message(&next_2, "fixture.example", ADDRESS, "1e21", ""),
727 1,
728 None,
729 )
730 .await;
731 assert_eq!(huge.status, 200, "{}", body(&huge));
732 assert!(
733 ctx.database
734 .get_user_accounts(user_id)
735 .await
736 .unwrap()
737 .iter()
738 .any(|account| account.account_id() == format!("{ADDRESS}:1e+21"))
739 );
740 let observed = verifier.inputs.lock().await;
741 assert_eq!(observed.len(), 3);
742 assert_eq!(observed[0].cacao.p.nonce, "GoldenNonce0001");
743 assert_eq!(observed[0].cacao.p.domain, "fixture.example");
744 assert_eq!(observed[0].cacao.p.aud, "fixture.example");
745 assert_eq!(observed[0].cacao.p.iss, "fixture.example");
746 assert_eq!(
747 observed[0].cacao.s.s,
748 fixture["signature"].as_str().unwrap()
749 );
750 assert_eq!(
751 observed
752 .get(1)
753 .expect("callback receives the second fixture")
754 .chain_id
755 .to_bits(),
756 16.0_f64.to_bits()
757 );
758 assert_eq!(
759 observed
760 .get(2)
761 .expect("third observed proof")
762 .chain_id
763 .to_bits(),
764 1e21_f64.to_bits()
765 );
766 }
767
768 #[tokio::test]
769 async fn signed_preferences_change_cookie_persistence_without_shortening_siwe_sessions() {
770 use alibi_core::utils::cookie_utils::{related_cookie_name, sign_cookie_value};
771 let (ctx, _) = context().await;
772 let (plugin, _) = plugin();
773 let preference_name = related_cookie_name(&ctx.config, "dont_remember");
774 for (preference, temporary) in [
775 (None, false),
776 (Some(""), false),
777 (Some("false"), true),
778 (Some("true"), true),
779 ] {
780 let nonce = issue(&plugin, &ctx, "/siwe/nonce").await;
781 let signed = message(&nonce, "fixture.example", ADDRESS, "1", "");
782 let mut req = request(
783 "/siwe/verify",
784 &(json!({"message":signed,"signature":sign(&signed,1)})),
785 );
786 if let Some(preference) = preference {
787 _ = req.headers.insert(
788 "cookie".into(),
789 format!(
790 "{preference_name}={}",
791 sign_cookie_value(preference, &ctx.config.secret)
792 ),
793 );
794 }
795 let response = plugin.on_request(&req, &ctx).await.unwrap().unwrap();
796 assert_eq!(response.status, 200);
797 let result = body(&response);
798 let session = ctx
799 .database
800 .get_session(result["token"].as_str().unwrap())
801 .await
802 .unwrap()
803 .unwrap();
804 assert!(
805 ((session.expires_at - session.created_at) - ctx.config.session.expires_in)
806 .num_milliseconds()
807 .abs()
808 < 1_000
809 );
810 let cookie = response
811 .headers
812 .get_all("Set-Cookie")
813 .find(|cookie| cookie.starts_with("better-auth.session_token="))
814 .unwrap();
815 assert_eq!(
816 !cookie.contains("Max-Age="),
817 temporary,
818 "preference={preference:?}"
819 );
820 assert_eq!(
821 response
822 .headers
823 .get_all("Set-Cookie")
824 .any(|candidate| candidate.starts_with(&format!("{preference_name}="))),
825 temporary
826 );
827 }
828 assert_eq!(
829 ctx.database
830 .get_user_sessions(
831 &ctx.database
832 .get_wallet_address(ADDRESS, Some(1.0))
833 .await
834 .unwrap()
835 .unwrap()
836 .user_id
837 )
838 .await
839 .unwrap()
840 .len(),
841 4
842 );
843 }
844}
845