RTIX / src /core /session.rs
github-actions
deploy: clean backend production release
d8ffec9
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<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)
}