Skip to main content

aetheris_server/auth/
mod.rs

1#![allow(clippy::missing_errors_doc)]
2#![allow(clippy::result_large_err)]
3#![allow(clippy::cast_sign_loss)]
4
5use aetheris_protocol::auth::v1::{
6    ConnectTokenRequest, ConnectTokenResponse, GoogleLoginNonceRequest, GoogleLoginNonceResponse,
7    LoginMethod, LoginRequest, LoginResponse, LogoutRequest, LogoutResponse, OtpRequest,
8    OtpRequestAck, QuicConnectToken, RefreshRequest, RefreshResponse,
9    auth_service_server::AuthService as GrpcAuthService, login_request::Method,
10};
11use async_trait::async_trait;
12
13#[async_trait]
14pub trait AuthService: Send + Sync {
15    async fn verify_session(&self, token: &str) -> Result<VerifiedSession, AuthError>;
16}
17use base64::Engine;
18use blake2::{Blake2b, Digest, digest::consts::U32};
19use chrono::{DateTime, Duration, Utc};
20use dashmap::DashMap;
21use rand::RngExt;
22use rusty_paseto::prelude::{
23    CustomClaim, ExpirationClaim, IssuedAtClaim, Key, Local, PasetoBuilder, PasetoParser,
24    PasetoSymmetricKey, SubjectClaim, TokenIdentifierClaim, V4,
25};
26use std::sync::Arc;
27use subtle::ConstantTimeEq;
28use thiserror::Error;
29use tonic::{Request, Response, Status};
30use tracing::warn;
31use ulid::Ulid;
32
33pub mod email;
34pub mod google;
35pub mod rate_limit;
36
37/// Details of a verified session.
38#[derive(Debug, Clone)]
39pub struct VerifiedSession {
40    /// The stable identifier for the player.
41    pub player_id: String,
42    /// The unique identifier for this session.
43    pub jti: String,
44}
45
46/// Errors that can occur during authentication or session verification.
47#[derive(Error, Debug)]
48pub enum AuthError {
49    #[error("Invalid session token")]
50    InvalidToken,
51    #[error("Session missing jti claim")]
52    MissingJti,
53    #[error("Session missing sub claim")]
54    MissingSub,
55    #[error("Session revoked or expired")]
56    SessionExpired,
57    #[error("Authentication rate limit exceeded: {0}")]
58    RateLimitExceeded(String),
59}
60
61impl From<AuthError> for tonic::Status {
62    fn from(err: AuthError) -> Self {
63        match err {
64            AuthError::RateLimitExceeded(msg) => Status::resource_exhausted(msg),
65            _ => Status::unauthenticated(err.to_string()),
66        }
67    }
68}
69
70/// Core trait for session verification and management.
71pub trait AuthSessionVerifier: std::fmt::Debug + Send + Sync + 'static {
72    /// Verifies a session token and returns the verified session details.
73    fn verify_session(&self, token: &str, tick: Option<u64>) -> Result<VerifiedSession, AuthError>;
74
75    /// Checks if a session JTI is still authorized.
76    fn is_session_authorized(&self, jti: &str, tick: Option<u64>) -> bool;
77}
78
79use email::EmailSender;
80use google::GoogleOidcClient;
81use rate_limit::{InMemoryRateLimiter, RateLimitType};
82
83pub struct OtpRecord {
84    pub email: String,
85    pub code_hash: Vec<u8>,
86    pub google_nonce: Option<String>,
87    pub expires_at: DateTime<Utc>,
88    pub attempts: u8,
89}
90
91#[derive(Clone)]
92pub struct AuthServiceImpl {
93    otp_store: Arc<DashMap<String, OtpRecord>>,
94    /// Maps Session JTI -> Last Activity Unix Timestamp
95    session_activity: Arc<DashMap<String, i64>>,
96    /// Maps Player ID -> () (existence check for P1)
97    player_registry: Arc<DashMap<String, ()>>,
98    email_sender: Arc<dyn EmailSender>,
99    google_client: Arc<Option<GoogleOidcClient>>,
100    pub(crate) session_key: Arc<PasetoSymmetricKey<V4, Local>>,
101    transport_key: Arc<PasetoSymmetricKey<V4, Local>>,
102    rate_limiter: InMemoryRateLimiter,
103    bypass_enabled: bool,
104}
105
106impl std::fmt::Debug for AuthServiceImpl {
107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        f.debug_struct("AuthServiceImpl").finish_non_exhaustive()
109    }
110}
111
112impl AuthServiceImpl {
113    /// Creates a new `AuthServiceImpl` with the provided `email_sender`.
114    ///
115    /// # Panics
116    ///
117    /// - If `AETHERIS_ENV` is set to "production" but `AETHERIS_AUTH_BYPASS` is enabled.
118    /// - If `AETHERIS_ENV` is set to "production" but `SESSION_PASETO_KEY` or `TRANSPORT_PASETO_KEY` are missing.
119    /// - If the provided PASETO keys are not exactly 32 bytes long.
120    pub async fn new(email_sender: Arc<dyn EmailSender>) -> Self {
121        let env = std::env::var("AETHERIS_ENV").unwrap_or_else(|_| "dev".to_string());
122
123        let session_key_str =
124            std::env::var("SESSION_PASETO_KEY").map_err(|_| "SESSION_PASETO_KEY missing");
125        let transport_key_str =
126            std::env::var("TRANSPORT_PASETO_KEY").map_err(|_| "TRANSPORT_PASETO_KEY missing");
127
128        let bypass_enabled = std::env::var("AETHERIS_AUTH_BYPASS").is_ok_and(|v| {
129            let v = v.to_lowercase();
130            v == "1" || v == "true" || v == "yes" || v == "on"
131        });
132
133        if env == "production" {
134            assert!(
135                !bypass_enabled,
136                "AETHERIS_AUTH_BYPASS=1 is forbidden in production"
137            );
138            assert!(
139                !(session_key_str.is_err() || transport_key_str.is_err()),
140                "PASETO keys must be explicitly set in production"
141            );
142        }
143
144        let session_key_val = session_key_str.unwrap_or_else(|_| {
145            assert!(env != "production", "Missing SESSION_PASETO_KEY");
146            "01234567890123456789012345678901".to_string()
147        });
148        let transport_key_val = transport_key_str.unwrap_or_else(|_| {
149            assert!(env != "production", "Missing TRANSPORT_PASETO_KEY");
150            "01234567890123456789012345678901".to_string()
151        });
152
153        assert!(
154            !(session_key_val.len() != 32 || transport_key_val.len() != 32),
155            "PASETO keys must be exactly 32 bytes"
156        );
157
158        let session_key =
159            PasetoSymmetricKey::<V4, Local>::from(Key::<32>::from(session_key_val.as_bytes()));
160        let transport_key =
161            PasetoSymmetricKey::<V4, Local>::from(Key::<32>::from(transport_key_val.as_bytes()));
162
163        let google_client = GoogleOidcClient::new().await.ok();
164
165        if bypass_enabled {
166            tracing::warn!(
167                "Authentication bypass is ENABLED (DEV ONLY) — 000001 code will work for smoke-test@aetheris.dev and bot_* addresses"
168            );
169        } else {
170            tracing::info!("Authentication bypass is disabled");
171        }
172
173        Self {
174            otp_store: Arc::new(DashMap::new()),
175            session_activity: Arc::new(DashMap::new()),
176            player_registry: Arc::new(DashMap::new()),
177            email_sender,
178            google_client: Arc::new(google_client),
179            session_key: Arc::new(session_key),
180            transport_key: Arc::new(transport_key),
181            rate_limiter: InMemoryRateLimiter::new(),
182            bypass_enabled,
183        }
184    }
185
186    /// Normalizes email according to M1005 spec: trim whitespace and lowercase the entire address.
187    fn normalize_email(email: &str) -> String {
188        email.trim().to_lowercase()
189    }
190
191    fn derive_player_id(method: &str, identifier: &str) -> String {
192        use sha2::{Digest, Sha256};
193        let mut hasher = Sha256::new();
194        hasher.update(format!("aetheris:{method}:{identifier}").as_bytes());
195        let hash = hasher.finalize();
196        let mut buf = [0u8; 16];
197        buf.copy_from_slice(&hash[0..16]);
198        Ulid::from(u128::from_be_bytes(buf)).to_string()
199    }
200
201    #[must_use]
202    pub fn is_authorized(&self, token: &str) -> bool {
203        AuthSessionVerifier::verify_session(self, token, None).is_ok()
204    }
205
206    /// Validates a session by JTI, checking for revocation and enforcing 1h sliding idle window.
207    ///
208    /// # Performance
209    /// if `tick` is provided, activity updates are coalesced to once per 60 ticks (~1s)
210    /// to reduce write-lock contention on the session map.
211    #[must_use]
212    pub fn is_session_authorized_with_tick(&self, jti: &str, tick: Option<u64>) -> bool {
213        // Optimistic Read: Check for existence and idle timeout without write lock first.
214        let (needs_update, now_ts) = if let Some(activity) = self.session_activity.get(jti) {
215            let now = Utc::now().timestamp();
216            // Idle timeout: 1 hour (3600 seconds)
217            if now - *activity > 3600 {
218                return false;
219            }
220
221            // Coalescing: Only update if tick is unavailable or it's a multiple of 60 (with jitter).
222            // We use a simple summation of jti bytes to stagger updates across the 60-tick window
223            // and avoid synchronized lock contention from 1000s of clients simultaneously.
224            let jitter = jti
225                .as_bytes()
226                .iter()
227                .fold(0u64, |acc, &x| acc.wrapping_add(u64::from(x)));
228            let needs_update = tick.is_none_or(|t| (t.wrapping_add(jitter)) % 60 == 0);
229            (needs_update, now)
230        } else {
231            // Not in activity map -> revoked or expired
232            return false;
233        };
234
235        if needs_update && let Some(mut activity) = self.session_activity.get_mut(jti) {
236            *activity = now_ts;
237        }
238
239        true
240    }
241
242    /// Mints a new session token for the given player.
243    ///
244    /// # Panics
245    ///
246    /// Panics if the current time cannot be formatted as RFC3339.
247    pub fn mint_session_token(
248        &self,
249        player_id: &str,
250        jti: Option<String>,
251    ) -> Result<(String, u64), Status> {
252        let jti = jti.unwrap_or_else(|| Ulid::new().to_string());
253        let iat = Utc::now();
254        let exp = iat + Duration::hours(24);
255
256        let token = PasetoBuilder::<V4, Local>::default()
257            .set_claim(SubjectClaim::from(player_id))
258            .set_claim(TokenIdentifierClaim::from(jti.as_str()))
259            .set_claim(IssuedAtClaim::try_from(iat.to_rfc3339().as_str()).unwrap())
260            .set_claim(ExpirationClaim::try_from(exp.to_rfc3339().as_str()).unwrap())
261            .build(&self.session_key)
262            .map_err(|e| Status::internal(format!("{e:?}")))?;
263
264        // Initialize session activity (store seconds, matched by is_session_authorized_with_tick)
265        self.session_activity.insert(jti, iat.timestamp());
266
267        Ok((token, exp.timestamp_millis() as u64))
268    }
269}
270
271impl AuthSessionVerifier for AuthServiceImpl {
272    fn verify_session(&self, token: &str, tick: Option<u64>) -> Result<VerifiedSession, AuthError> {
273        let claims = PasetoParser::<V4, Local>::default()
274            .parse(token, &self.session_key)
275            .map_err(|_| AuthError::InvalidToken)?;
276
277        let jti = claims
278            .get("jti")
279            .and_then(|v| v.as_str())
280            .ok_or(AuthError::MissingJti)?;
281        let sub = claims
282            .get("sub")
283            .and_then(|v| v.as_str())
284            .ok_or(AuthError::MissingSub)?;
285
286        if self.is_session_authorized_with_tick(jti, tick) {
287            Ok(VerifiedSession {
288                player_id: sub.to_string(),
289                jti: jti.to_string(),
290            })
291        } else {
292            Err(AuthError::SessionExpired)
293        }
294    }
295
296    fn is_session_authorized(&self, jti: &str, tick: Option<u64>) -> bool {
297        self.is_session_authorized_with_tick(jti, tick)
298    }
299}
300
301#[async_trait]
302impl AuthService for AuthServiceImpl {
303    async fn verify_session(&self, token: &str) -> Result<VerifiedSession, AuthError> {
304        AuthSessionVerifier::verify_session(self, token, None)
305    }
306}
307
308#[async_trait]
309impl GrpcAuthService for AuthServiceImpl {
310    async fn request_otp(
311        &self,
312        request: Request<OtpRequest>,
313    ) -> Result<Response<OtpRequestAck>, Status> {
314        // M10146 — Rate Limiting (M1005 §3.4.2)
315        if !self.bypass_enabled {
316            // 1. IP-based limit (30/h)
317            if let Some(addr) = request.remote_addr() {
318                let ip = addr.ip().to_string();
319                self.rate_limiter.check_limit(RateLimitType::Ip, &ip)?;
320            } else {
321                warn!("Request missing remote_addr; IP rate limiting skipped (check proxy config)");
322            }
323        }
324
325        let req = request.into_inner();
326        let email = Self::normalize_email(&req.email);
327
328        // 2. Email-based limit (5/h)
329        if !self.bypass_enabled {
330            self.rate_limiter
331                .check_limit(RateLimitType::Email, &email)?;
332        }
333
334        let mut rng = rand::rng();
335        let code = format!("{:06}", rng.random_range(0..1_000_000));
336        let request_id = Ulid::new().to_string();
337        let expires_at = Utc::now() + Duration::minutes(10);
338
339        let mut hasher = Blake2b::<U32>::new();
340        hasher.update(code.as_bytes());
341        hasher.update(request_id.as_bytes());
342        let code_hash = hasher.finalize().to_vec();
343
344        self.otp_store.insert(
345            request_id.clone(),
346            OtpRecord {
347                email: email.clone(),
348                code_hash,
349                google_nonce: None,
350                expires_at,
351                attempts: 0,
352            },
353        );
354
355        let sender = self.email_sender.clone();
356        let code_clone = code.clone();
357        let env = std::env::var("AETHERIS_ENV").unwrap_or_else(|_| "dev".to_string());
358        if env == "production" {
359            tracing::info!(request_id = %request_id, "Generated OTP");
360        } else {
361            tracing::info!(request_id = %request_id, email = %email, code = %code, "Generated OTP (DEV ONLY)");
362        }
363        tokio::spawn(async move {
364            let _ = sender
365                .send(
366                    &email,
367                    "Your Aetheris OTP",
368                    &format!("Code: {code_clone}"),
369                    &format!("<h1>Code: {code_clone}</h1>"),
370                )
371                .await;
372        });
373
374        Ok(Response::new(OtpRequestAck {
375            request_id,
376            expires_at_unix_ms: expires_at.timestamp_millis() as u64,
377            retry_after_seconds: Some(0), // 0 on normal path per spec
378        }))
379    }
380
381    #[allow(clippy::too_many_lines)]
382    async fn login(
383        &self,
384        request: Request<LoginRequest>,
385    ) -> Result<Response<LoginResponse>, Status> {
386        let req = request.into_inner();
387        let metadata = req.metadata.unwrap_or_default();
388        let method = req
389            .method
390            .ok_or_else(|| Status::invalid_argument("Missing login method"))?;
391
392        tracing::info!(
393            version = metadata.client_version,
394            platform = metadata.platform,
395            "Processing login request"
396        );
397
398        match method {
399            Method::Otp(otp_req) => {
400                let (request_id, code) = (otp_req.request_id, otp_req.code);
401
402                // T100.20 — decisional delay before first store access to mitigate timing side-channels
403                tokio::time::sleep(std::time::Duration::from_millis(15)).await;
404
405                let mut entry = self
406                    .otp_store
407                    .get_mut(&request_id)
408                    .ok_or_else(|| Status::unauthenticated("Invalid credentials"))?;
409
410                // M1005 §3.4.1 — Authentication Bypass for Automated Integration Tests
411                // This block allows 'smoke-test@aetheris.dev' to login with pre-defined codes
412                // when AETHERIS_AUTH_BYPASS is enabled (strictly DEV/TEST environments only).
413                // TODO: In Phase 4, consider moving this logic to a dedicated Auth Sidecar.
414                if self.bypass_enabled
415                    && (entry.email == "smoke-test@aetheris.dev" || entry.email.starts_with("bot_"))
416                {
417                    // "000001" is the canonical 'Success' code for automated smoke tests and playground.
418                    if code == "000001" {
419                        tracing::warn!(email = entry.email, "Bypass authentication successful");
420                        let player_id = Self::derive_player_id("email", &entry.email);
421
422                        // Check if new player
423                        let is_new_player =
424                            self.player_registry.insert(player_id.clone(), ()).is_none();
425
426                        let (token, exp) =
427                            self.mint_session_token(&player_id, Some("admin".to_string()))?;
428                        drop(entry);
429                        self.otp_store.remove(&request_id);
430
431                        return Ok(Response::new(LoginResponse {
432                            session_token: token,
433                            expires_at_unix_ms: exp,
434                            player_id,
435                            is_new_player,
436                            login_method: LoginMethod::EmailOtp as i32,
437                        }));
438                    }
439
440                    // "000000" is the canonical 'Failure' code used to validate client-side error handling.
441                    if code == "000000" {
442                        entry.attempts += 1;
443                        if entry.attempts >= 3 {
444                            drop(entry);
445                            self.otp_store.remove(&request_id);
446                        }
447                        return Err(Status::unauthenticated("Bypass: Forced failure for 000000"));
448                    }
449                }
450
451                if Utc::now() > entry.expires_at {
452                    drop(entry);
453                    self.otp_store.remove(&request_id);
454                    return Err(Status::deadline_exceeded("OTP expired"));
455                }
456
457                let mut hasher = Blake2b::<U32>::new();
458                hasher.update(code.as_bytes());
459                hasher.update(request_id.as_bytes());
460                let hash = hasher.finalize();
461
462                if hash.as_slice().ct_eq(&entry.code_hash).into() {
463                    let player_id = Self::derive_player_id("email", &entry.email);
464
465                    // Check if new player
466                    let is_new_player =
467                        self.player_registry.insert(player_id.clone(), ()).is_none();
468
469                    let (token, exp) = self.mint_session_token(&player_id, None)?;
470                    drop(entry);
471                    self.otp_store.remove(&request_id);
472
473                    Ok(Response::new(LoginResponse {
474                        session_token: token,
475                        expires_at_unix_ms: exp,
476                        player_id,
477                        is_new_player,
478                        login_method: LoginMethod::EmailOtp as i32,
479                    }))
480                } else {
481                    entry.attempts += 1;
482                    if entry.attempts >= 3 {
483                        drop(entry);
484                        self.otp_store.remove(&request_id);
485                    }
486                    Err(Status::unauthenticated("Invalid code"))
487                }
488            }
489            Method::Google(google_req) => {
490                let google_client = self
491                    .google_client
492                    .as_ref()
493                    .as_ref()
494                    .ok_or_else(|| Status::internal("Google OIDC not configured"))?;
495
496                let nonce = self
497                    .otp_store
498                    .get(&google_req.nonce_handle)
499                    .and_then(|e| e.google_nonce.clone())
500                    .ok_or_else(|| Status::unauthenticated("Invalid nonce_handle"))?;
501
502                let claims = google_client.verify_token(&google_req.google_id_token, &nonce)?;
503                self.otp_store.remove(&google_req.nonce_handle);
504
505                // Use 'sub' (Subject) as the stable identifier for the player instead of email.
506                let identity = claims.subject().to_string();
507                let player_id = Self::derive_player_id("google", &identity);
508
509                // Check if new player
510                let is_new_player = self.player_registry.insert(player_id.clone(), ()).is_none();
511
512                let (token, exp) = self.mint_session_token(&player_id, None)?;
513
514                Ok(Response::new(LoginResponse {
515                    session_token: token,
516                    expires_at_unix_ms: exp,
517                    player_id,
518                    is_new_player,
519                    login_method: LoginMethod::GoogleOidc as i32,
520                }))
521            }
522        }
523    }
524
525    async fn logout(
526        &self,
527        request: Request<LogoutRequest>,
528    ) -> Result<Response<LogoutResponse>, Status> {
529        let mut jti_to_revoke = None;
530
531        let token_from_metadata = request.metadata().get("authorization").and_then(|t| {
532            t.to_str()
533                .ok()
534                .map(|s| s.trim_start_matches("Bearer ").to_string())
535        });
536
537        let token_str = token_from_metadata.or_else(|| {
538            let body = request.get_ref();
539            if body.session_token.is_empty() {
540                None
541            } else {
542                Some(body.session_token.clone())
543            }
544        });
545
546        if let Some(token_clean) = token_str
547            && let Ok(claims) =
548                PasetoParser::<V4, Local>::default().parse(&token_clean, &self.session_key)
549            && let Some(jti) = claims.get("jti").and_then(|v| v.as_str())
550        {
551            jti_to_revoke = Some(jti.to_string());
552        }
553
554        if let Some(jti) = jti_to_revoke {
555            self.session_activity.remove(&jti);
556        }
557
558        Ok(Response::new(LogoutResponse { revoked: true }))
559    }
560
561    async fn refresh_token(
562        &self,
563        request: Request<RefreshRequest>,
564    ) -> Result<Response<RefreshResponse>, Status> {
565        let req = request.into_inner();
566        let token = req.session_token;
567
568        let Ok(claims) = PasetoParser::<V4, Local>::default().parse(&token, &self.session_key)
569        else {
570            return Err(Status::unauthenticated("Invalid session token"));
571        };
572
573        let Some(jti) = claims.get("jti").and_then(|v| v.as_str()) else {
574            return Err(Status::unauthenticated("Token missing jti"));
575        };
576
577        let Some(sub) = claims.get("sub").and_then(|v| v.as_str()) else {
578            return Err(Status::unauthenticated("Token missing sub"));
579        };
580
581        if !self.is_session_authorized_with_tick(jti, None) {
582            return Err(Status::unauthenticated("Session revoked or expired"));
583        }
584
585        // Revoke old token
586        self.session_activity.remove(jti);
587
588        // Mint new token (preserve admin status if old token was admin)
589        let new_jti = if jti == "admin" {
590            Some("admin".to_string())
591        } else {
592            None
593        };
594        let (new_token, exp) = self.mint_session_token(sub, new_jti)?;
595
596        Ok(Response::new(RefreshResponse {
597            session_token: new_token,
598            expires_at_unix_ms: exp,
599        }))
600    }
601
602    async fn issue_connect_token(
603        &self,
604        request: Request<ConnectTokenRequest>,
605    ) -> Result<Response<ConnectTokenResponse>, Status> {
606        let req = request.into_inner();
607
608        let client_id = rand::random::<u64>();
609        let mut rng = rand::rng();
610        let mut nonce = [0u8; 24];
611        rng.fill(&mut nonce);
612        let server_nonce = base64::engine::general_purpose::STANDARD.encode(nonce);
613
614        let iat = Utc::now();
615        let exp = iat + Duration::minutes(5);
616
617        let token = PasetoBuilder::<V4, Local>::default()
618            .set_claim(CustomClaim::try_from(("client_id", serde_json::json!(client_id))).unwrap())
619            .set_claim(
620                CustomClaim::try_from(("server", serde_json::json!(req.server_address))).unwrap(),
621            )
622            .set_claim(
623                CustomClaim::try_from(("server_nonce", serde_json::json!(server_nonce))).unwrap(),
624            )
625            .set_claim(IssuedAtClaim::try_from(iat.to_rfc3339().as_str()).unwrap())
626            .set_claim(ExpirationClaim::try_from(exp.to_rfc3339().as_str()).unwrap())
627            .build(&self.transport_key)
628            .map_err(|e| Status::internal(format!("{e:?}")))?;
629
630        Ok(Response::new(ConnectTokenResponse {
631            token: Some(QuicConnectToken {
632                paseto: token,
633                server_address: req.server_address,
634                expires_at_unix_ms: exp.timestamp_millis() as u64,
635                client_id,
636            }),
637        }))
638    }
639
640    async fn create_google_login_nonce(
641        &self,
642        _request: Request<GoogleLoginNonceRequest>,
643    ) -> Result<Response<GoogleLoginNonceResponse>, Status> {
644        let mut nonce_bytes = [0u8; 16];
645        rand::rng().fill(&mut nonce_bytes);
646        let nonce = hex::encode(nonce_bytes);
647
648        let nonce_handle = Ulid::new().to_string();
649        let expires_at = Utc::now() + Duration::minutes(10);
650
651        self.otp_store.insert(
652            nonce_handle.clone(),
653            OtpRecord {
654                email: String::new(),
655                code_hash: Vec::new(),
656                google_nonce: Some(nonce.clone()),
657                expires_at,
658                attempts: 0,
659            },
660        );
661
662        Ok(Response::new(GoogleLoginNonceResponse {
663            nonce_handle,
664            nonce,
665            expires_at_unix_ms: expires_at.timestamp_millis() as u64,
666        }))
667    }
668}