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#[derive(Debug, Clone)]
39pub struct VerifiedSession {
40 pub player_id: String,
42 pub jti: String,
44}
45
46#[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
70pub trait AuthSessionVerifier: std::fmt::Debug + Send + Sync + 'static {
72 fn verify_session(&self, token: &str, tick: Option<u64>) -> Result<VerifiedSession, AuthError>;
74
75 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 session_activity: Arc<DashMap<String, i64>>,
96 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 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 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 #[must_use]
212 pub fn is_session_authorized_with_tick(&self, jti: &str, tick: Option<u64>) -> bool {
213 let (needs_update, now_ts) = if let Some(activity) = self.session_activity.get(jti) {
215 let now = Utc::now().timestamp();
216 if now - *activity > 3600 {
218 return false;
219 }
220
221 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 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 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 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 if !self.bypass_enabled {
316 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 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), }))
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 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 if self.bypass_enabled
415 && (entry.email == "smoke-test@aetheris.dev" || entry.email.starts_with("bot_"))
416 {
417 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 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 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 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 let identity = claims.subject().to_string();
507 let player_id = Self::derive_player_id("google", &identity);
508
509 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 self.session_activity.remove(jti);
587
588 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}