Skip to main content

alibi_plugins/siwe/
mod.rs

1//! Sign in with Ethereum using a single-use challenge and application-owned
2//! signature verification, including contract-wallet RPC providers.
3
4mod 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            // The installed SIWE endpoint catches ordinary serializer errors and
234            // returns its own 401 body with the serializer message.
235            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        // Keep the reference ordering: ENS failures leave an email claim in
290        // place. User-creation success/failure below always consumes its claim.
291        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// LCOV_EXCL_START
446#[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// LCOV_EXCL_STOP