Spaces:
Running
Running
| use std::env; | |
| use std::time::{SystemTime, UNIX_EPOCH}; | |
| use axum::http::HeaderMap; | |
| use jsonwebtoken::{DecodingKey, EncodingKey}; | |
| use rand::Rng; | |
| use serde::{Deserialize, Serialize}; | |
| use crate::domain::constants::{ | |
| ACCESS_TOKEN_TTL_SECS, AUTH_COOKIE_NAME, CSRF_COOKIE_NAME, PROOF_TOKEN_PURPOSE, | |
| PROOF_TOKEN_TTL_SECS, | |
| }; | |
| pub struct ProofClaims { | |
| pub sub: String, | |
| pub purpose: String, | |
| pub exp: usize, | |
| } | |
| pub fn jwt_secret() -> Vec<u8> { | |
| env::var("JWT_SECRET") | |
| .expect("JWT_SECRET environment variable must be set. Refusing to use a fallback.") | |
| .into_bytes() | |
| } | |
| pub fn access_token_expiry() -> usize { | |
| now_epoch() + ACCESS_TOKEN_TTL_SECS | |
| } | |
| pub fn extract_auth_token(headers: &HeaderMap) -> Option<String> { | |
| headers | |
| .get(axum::http::header::AUTHORIZATION) | |
| .and_then(|value| value.to_str().ok()) | |
| .and_then(|value| value.strip_prefix("Bearer ")) | |
| .map(ToString::to_string) | |
| .or_else(|| extract_cookie(headers, AUTH_COOKIE_NAME)) | |
| } | |
| pub fn extract_cookie(headers: &HeaderMap, name: &str) -> Option<String> { | |
| let cookie_header = headers.get(axum::http::header::COOKIE)?.to_str().ok()?; | |
| cookie_header.split(';').find_map(|entry| { | |
| let trimmed = entry.trim(); | |
| let (cookie_name, cookie_value) = trimmed.split_once('=')?; | |
| if cookie_name == name { | |
| Some(cookie_value.to_string()) | |
| } else { | |
| None | |
| } | |
| }) | |
| } | |
| pub fn allowed_origins_list() -> Vec<String> { | |
| let origins = env::var("ALLOWED_ORIGINS") | |
| .unwrap_or_else(|_| "http://localhost:5173".to_string()) | |
| .split(',') | |
| .map(|s| { | |
| let trimmed = s.trim(); | |
| // Normalize by removing trailing slash for exact matching | |
| trimmed.strip_suffix('/').unwrap_or(trimmed).to_string() | |
| }) | |
| .collect::<Vec<_>>(); | |
| tracing::info!("CORS allowed origins: {:?}", origins); | |
| origins | |
| } | |
| fn is_cloud_env() -> bool { | |
| // Detect any cloud environment: Render, Koyeb, Railway, Fly, Hugging Face, custom | |
| std::env::var("RENDER").is_ok() | |
| || std::env::var("KOYEB").is_ok() | |
| || std::env::var("CLOUD_ENV").is_ok() | |
| || std::env::var("RAILWAY_ENVIRONMENT").is_ok() | |
| || std::env::var("FLY_APP_NAME").is_ok() | |
| || std::env::var("SPACE_ID").is_ok() | |
| } | |
| pub fn origin_is_allowed(origin: Option<&str>) -> bool { | |
| let Some(origin) = origin else { | |
| // In local development, some tools or configurations might strip the Origin header. | |
| // We log it and allow it ONLY if not in a cloud production environment. | |
| if !is_cloud_env() { | |
| tracing::debug!("No Origin header present. Allowing for local dev compatibility."); | |
| return true; | |
| } | |
| tracing::warn!("Blocked request with missing Origin header in production."); | |
| return false; | |
| }; | |
| // Normalize the incoming origin by removing trailing slash if present | |
| let normalized_origin = origin.strip_suffix('/').unwrap_or(origin); | |
| // ─── Production Convenience: Automatically allow all Vercel + Koyeb subdomains ─── | |
| if is_cloud_env() | |
| && (normalized_origin.ends_with(".vercel.app") | |
| || normalized_origin.ends_with(".koyeb.app") | |
| || normalized_origin.ends_with(".netlify.app")) | |
| { | |
| tracing::debug!("CORS allowed cloud origin: {}", normalized_origin); | |
| return true; | |
| } | |
| let allowed_list = allowed_origins_list(); | |
| let is_allowed = allowed_list | |
| .iter() | |
| .any(|allowed| allowed == "*" || allowed == normalized_origin); | |
| if !is_allowed { | |
| tracing::error!( | |
| "CORS BLOCKED ORIGIN: {} (Allowed: {:?})", | |
| normalized_origin, | |
| allowed_list | |
| ); | |
| } else { | |
| tracing::debug!("CORS allowed origin: {}", normalized_origin); | |
| } | |
| is_allowed | |
| } | |
| pub fn generate_random_token(length: usize) -> String { | |
| let charset = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; | |
| let mut rng = rand::thread_rng(); | |
| (0..length) | |
| .map(|_| { | |
| let idx = rng.gen_range(0..charset.len()); | |
| charset[idx] as char | |
| }) | |
| .collect() | |
| } | |
| pub fn build_cookie(name: &str, value: &str, max_age: Option<usize>, http_only: bool) -> String { | |
| let secure = use_secure_cookies(); | |
| // For cross-origin requests (Vercel -> Render), we MUST use SameSite=None with Secure. | |
| // Otherwise, Lax (the default) will block the cookie on cross-site subresource requests. | |
| let samesite = if secure { "None" } else { "Lax" }; | |
| let mut cookie = format!("{name}={value}; Path=/; SameSite={samesite}"); | |
| if let Some(max_age) = max_age { | |
| cookie.push_str(&format!("; Max-Age={max_age}")); | |
| } | |
| if http_only { | |
| cookie.push_str("; HttpOnly"); | |
| } | |
| if secure { | |
| cookie.push_str("; Secure"); | |
| } | |
| cookie | |
| } | |
| pub fn expired_cookie(name: &str) -> String { | |
| build_cookie(name, "", Some(0), true) | |
| } | |
| pub fn csrf_cookie(token: &str) -> String { | |
| build_cookie(CSRF_COOKIE_NAME, token, Some(PROOF_TOKEN_TTL_SECS), true) | |
| } | |
| pub fn encoding_key() -> EncodingKey { | |
| EncodingKey::from_secret(&jwt_secret()) | |
| } | |
| pub fn decoding_key() -> DecodingKey { | |
| DecodingKey::from_secret(&jwt_secret()) | |
| } | |
| pub fn issue_proof_token(transaction_id: &str) -> Result<String, jsonwebtoken::errors::Error> { | |
| jsonwebtoken::encode( | |
| &jsonwebtoken::Header::default(), | |
| &ProofClaims { | |
| sub: transaction_id.to_string(), | |
| purpose: PROOF_TOKEN_PURPOSE.to_string(), | |
| exp: now_epoch() + PROOF_TOKEN_TTL_SECS, | |
| }, | |
| &encoding_key(), | |
| ) | |
| } | |
| pub fn verify_proof_token( | |
| token: &str, | |
| transaction_id: &str, | |
| ) -> Result<ProofClaims, jsonwebtoken::errors::Error> { | |
| let mut validation = jsonwebtoken::Validation::default(); | |
| validation.validate_exp = true; | |
| let token_data = jsonwebtoken::decode::<ProofClaims>(token, &decoding_key(), &validation)?; | |
| if token_data.claims.sub == transaction_id && token_data.claims.purpose == PROOF_TOKEN_PURPOSE { | |
| Ok(token_data.claims) | |
| } else { | |
| Err(jsonwebtoken::errors::Error::from( | |
| jsonwebtoken::errors::ErrorKind::InvalidToken, | |
| )) | |
| } | |
| } | |
| fn use_secure_cookies() -> bool { | |
| // Force secure cookies if explicitly set or running in any cloud environment | |
| if let Ok(value) = env::var("COOKIE_SECURE") { | |
| return matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"); | |
| } | |
| // Auto-detect cloud environment and enable secure cookies | |
| if is_cloud_env() { | |
| return true; | |
| } | |
| env::var("FRONTEND_URL") | |
| .map(|url| url.starts_with("https://")) | |
| .unwrap_or(false) | |
| } | |
| pub fn now_epoch() -> usize { | |
| SystemTime::now() | |
| .duration_since(UNIX_EPOCH) | |
| .map(|d| d.as_secs() as usize) | |
| .unwrap_or(0) | |
| } | |