//! Authentication service: magic link token generation and session management. //! //! This module coordinates the auth flow between the token utilities, //! database queries, and email service. use chrono::{TimeDelta, Utc}; use sqlx::PgPool; use uuid::Uuid; use crate::db; use crate::errors::AppError; use crate::models::user::{User, UserRole}; use crate::services::email; use crate::util::token; /// Magic link token expiry: 15 minutes. const MAGIC_LINK_EXPIRY_MINUTES: i64 = 15; /// Session expiry: 30 days. const SESSION_EXPIRY_DAYS: i64 = 30; /// Maximum active (unused, non-expired) magic link tokens per email address. const MAX_ACTIVE_TOKENS_PER_EMAIL: i64 = 5; /// Generate a magic link token for the given email. /// /// Creates the token, stores its hash in the database, and returns the raw /// token (to be included in the magic link URL). The raw token is never stored. /// /// Returns `None` if the rate limit for this email has been exceeded. pub async fn create_magic_link(pool: &PgPool, email: &str) -> Result, AppError> { // Check rate limit: max active tokens per email let active_count = db::magic_links::count_active_for_email(pool, email).await?; if active_count >= MAX_ACTIVE_TOKENS_PER_EMAIL { tracing::warn!( email = email, active_count = active_count, "Too many active magic link tokens for email" ); return Ok(None); } let raw_token = token::generate_token(); let token_hash = token::hash_token(&raw_token); let expires_at = Utc::now() + TimeDelta::try_minutes(MAGIC_LINK_EXPIRY_MINUTES) .expect("valid minutes value"); db::magic_links::create(pool, email, &token_hash, expires_at).await?; tracing::info!(email = email, "Magic link token created"); Ok(Some(raw_token)) } /// Create a magic link token, send it by email, and roll back if delivery fails. /// /// Combines token creation and email sending with automatic cleanup: if the /// email fails to send, the token is deleted so it doesn't consume a quota slot. pub async fn create_and_send_magic_link( pool: &PgPool, http_client: &reqwest::Client, resend_api_key: &str, email_from: &str, app_url: &str, to_email: &str, ) -> Result<(), AppError> { let Some(raw_token) = create_magic_link(pool, to_email).await? else { return Ok(()); }; if let Err(e) = email::send_magic_link(http_client, resend_api_key, email_from, to_email, app_url, &raw_token).await { let token_hash = token::hash_token(&raw_token); db::magic_links::delete_by_hash(pool, &token_hash).await.ok(); return Err(e); } Ok(()) } /// Verify a magic link token and return the associated email. /// /// The token is consumed atomically (marked as used) to enforce single-use. /// Returns `None` if the token is invalid, already used, or expired. pub async fn verify_magic_link(pool: &PgPool, raw_token: &str) -> Result, AppError> { let token_hash = token::hash_token(raw_token); db::magic_links::consume(pool, &token_hash).await } /// Create a new session for the given user and return the raw session token. /// /// The raw token is sent to the client as a cookie; only its SHA-256 hash /// is stored in the database. pub async fn create_session( pool: &PgPool, user_id: Uuid, ip_address: Option<&str>, user_agent: Option<&str>, ) -> Result { let raw_token = token::generate_token(); let session_hash = token::hash_token(&raw_token); let expires_at = Utc::now() + TimeDelta::try_days(SESSION_EXPIRY_DAYS) .expect("valid days value"); db::sessions::create(pool, &session_hash, user_id, expires_at, ip_address, user_agent).await?; tracing::info!(user_id = %user_id, "Session created"); Ok(raw_token) } /// Ensure a user exists for the given email, creating one if necessary. /// /// Used during magic link verification: the user may have registered /// and verified in a single flow, so we create the user on first verification /// if they don't already exist. pub async fn ensure_user( pool: &PgPool, email: &str, display_name: Option<&str>, ) -> Result { match db::users::find_by_email(pool, email).await? { Some(user) => Ok(user), None => { let user = db::users::create(pool, email, display_name, UserRole::User).await?; tracing::info!(email = email, user_id = %user.id, "New user created during verification"); Ok(user) } } } /// Session cookie name. pub const SESSION_COOKIE_NAME: &str = "ai_synth_session"; /// Session cookie max age in seconds (30 days). pub const SESSION_COOKIE_MAX_AGE: i64 = 30 * 24 * 60 * 60;