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