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