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