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, }; #[derive(Debug, Serialize, Deserialize)] pub struct ProofClaims { pub sub: String, pub purpose: String, pub exp: usize, } pub fn jwt_secret() -> Vec { 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 { 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 { 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 { 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::>(); 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, 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 { 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 { let mut validation = jsonwebtoken::Validation::default(); validation.validate_exp = true; let token_data = jsonwebtoken::decode::(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) }