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;
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            // The installed SIWE endpoint catches ordinary serializer errors and
250            // returns its own 401 body with the serializer message.
251            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        // Keep the reference ordering: ENS failures leave an email claim in
306        // place. User-creation success/failure below always consumes its claim.
307        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// LCOV_EXCL_START
465#[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// LCOV_EXCL_STOP