lmrc-cli 0.3.16

CLI tool for scaffolding LMRC Stack infrastructure projects
Documentation
//! User application authentication provider
//!
//! Authenticates application users against user_db
//! Uses: users and sessions tables (from user application database)
//!
//! This allows user applications to integrate with the gateway for authentication

use async_trait::async_trait;
use chrono::{Duration, Utc};
use sea_orm::{DatabaseConnection, Statement, ConnectionTrait};
use uuid::Uuid;

use crate::auth::ports::{AuthProvider, AuthUser, Session};
use crate::error::{AppError, Result};

/// User application authentication provider
///
/// Manages authentication for application users
/// User services can delegate auth to the gateway or handle their own
pub struct UserAuthProvider {
    db: DatabaseConnection,
    session_expiration_hours: i64,
}

impl UserAuthProvider {
    pub fn new(db: DatabaseConnection, session_expiration_hours: i64) -> Self {
        Self {
            db,
            session_expiration_hours,
        }
    }
}

#[async_trait]
impl AuthProvider for UserAuthProvider {
    async fn authenticate(&self, email: &str, password: &str) -> Result<AuthUser> {
        // Query user_db.users table
        let sql = "SELECT id, email, password_hash FROM users WHERE email = $1";
        let stmt = Statement::from_sql_and_values(
            sea_orm::DatabaseBackend::Postgres,
            sql,
            vec![email.into()],
        );

        let result = self.db.query_one(stmt).await?;

        let user = result.ok_or_else(|| {
            AppError::Unauthorized("Invalid email or password".to_string())
        })?;

        let id: i64 = user.try_get("", "id")?;
        let stored_hash: String = user.try_get("", "password_hash")?;
        let user_email: String = user.try_get("", "email")?;

        // Verify password
        let is_valid = lmrc_http_common::auth::verify_password(password, &stored_hash)
            .map_err(|e| AppError::Auth(format!("Password verification failed: {}", e)))?;

        if !is_valid {
            return Err(AppError::Unauthorized("Invalid email or password".to_string()));
        }

        Ok(AuthUser {
            id,
            email: user_email,
            role: "user".to_string(), // User apps typically don't have roles, or define their own
        })
    }

    async fn create_session(&self, user_id: i64) -> Result<Session> {
        let token = Uuid::new_v4().to_string();
        let expires_at = Utc::now() + Duration::hours(self.session_expiration_hours);

        let sql = "INSERT INTO sessions (token, user_id, expires_at, created_at) VALUES ($1, $2, $3, $4)";
        let stmt = Statement::from_sql_and_values(
            sea_orm::DatabaseBackend::Postgres,
            sql,
            vec![
                token.clone().into(),
                user_id.into(),
                expires_at.naive_utc().into(),
                Utc::now().naive_utc().into(),
            ],
        );

        self.db.execute(stmt).await?;

        Ok(Session {
            token,
            user_id,
            expires_at: expires_at.naive_utc(),
        })
    }

    async fn validate_session(&self, token: &str) -> Result<Option<AuthUser>> {
        let sql = r#"
            SELECT u.id, u.email
            FROM sessions s
            JOIN users u ON s.user_id = u.id
            WHERE s.token = $1 AND s.expires_at > $2
        "#;
        let stmt = Statement::from_sql_and_values(
            sea_orm::DatabaseBackend::Postgres,
            sql,
            vec![token.into(), Utc::now().naive_utc().into()],
        );

        let result = self.db.query_one(stmt).await?;

        match result {
            Some(row) => {
                let id: i64 = row.try_get("", "id")?;
                let email: String = row.try_get("", "email")?;

                Ok(Some(AuthUser {
                    id,
                    email,
                    role: "user".to_string(),
                }))
            }
            None => Ok(None),
        }
    }

    async fn destroy_session(&self, token: &str) -> Result<()> {
        let sql = "DELETE FROM sessions WHERE token = $1";
        let stmt = Statement::from_sql_and_values(
            sea_orm::DatabaseBackend::Postgres,
            sql,
            vec![token.into()],
        );

        self.db.execute(stmt).await?;
        Ok(())
    }

    async fn get_user(&self, user_id: i64) -> Result<Option<AuthUser>> {
        let sql = "SELECT id, email FROM users WHERE id = $1";
        let stmt = Statement::from_sql_and_values(
            sea_orm::DatabaseBackend::Postgres,
            sql,
            vec![user_id.into()],
        );

        let result = self.db.query_one(stmt).await?;

        match result {
            Some(row) => {
                let id: i64 = row.try_get("", "id")?;
                let email: String = row.try_get("", "email")?;

                Ok(Some(AuthUser {
                    id,
                    email,
                    role: "user".to_string(),
                }))
            }
            None => Ok(None),
        }
    }
}

/// Helper to determine which auth provider to use based on subdomain
pub enum AuthContext {
    /// Infrastructure authentication (infra.* subdomain)
    Infrastructure,
    /// User application authentication (app.* subdomain)
    UserApp,
}

impl AuthContext {
    /// Determine auth context from subdomain
    pub fn from_subdomain(subdomain: &str) -> Self {
        if subdomain == "infra" {
            Self::Infrastructure
        } else {
            Self::UserApp
        }
    }
}