Skip to main content

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