slave 0.4.0

Laravel-inspired CLI for Rust MCP servers: scaffold apps whose business operations are MCP tools over Streamable HTTP, with built-in auth, users, roles and Turso migrations
//! Caller authentication for MCP tools: [`AuthUser`].
//!
//! This lives in `base` rather than in the `auth` module because every module's
//! tools check it; keeping it here means modules never depend on each other
//! just to check who is calling.
//!
//! The bearer token travels in the `Authorization` header of each MCP HTTP
//! request. Tools receive those headers through `Extension<Parts>` and pass
//! them to [`AuthUser::authenticate`] (usually via `McpServer::authenticate`).

use axum::http::HeaderMap;
use axum::http::header::AUTHORIZATION;

use super::error::{AppError, AppResult};
use super::state::AppState;

/// Role with full access, granted to the first registered user.
pub const ADMIN_ROLE: &str = "admin";
/// Role granted to every other newly registered user.
pub const USER_ROLE: &str = "user";

/// The user calling a tool, authenticated by `Authorization: Bearer <token>`.
#[derive(Debug, Clone)]
pub struct AuthUser {
    pub id: i64,
    pub name: String,
    pub email: String,
    pub roles: Vec<String>,
    token_hash: String,
}

impl AuthUser {
    /// Resolves the caller from a valid, unexpired bearer token in `headers`.
    pub async fn authenticate(state: &AppState, headers: &HeaderMap) -> AppResult<Self> {
        let token = bearer_token(headers).ok_or(AppError::Unauthorized)?;
        let token_hash = slave_framework::auth::hash_token(token);
        let conn = state.database.connect().await?;

        let mut rows = conn
            .query(
                "SELECT u.id, u.name, u.email FROM auth_tokens t
                 JOIN users u ON u.id = t.user_id
                 WHERE t.token_hash = ?1 AND t.expires_at > CURRENT_TIMESTAMP",
                (token_hash.as_str(),),
            )
            .await?;
        let Some(row) = rows.next().await? else {
            return Err(AppError::Unauthorized);
        };
        let (id, name, email) = (row.get(0)?, row.get(1)?, row.get(2)?);
        drop(rows);

        let mut rows = conn
            .query(
                "SELECT r.name FROM roles r
                 JOIN user_roles ur ON ur.role_id = r.id
                 WHERE ur.user_id = ?1 ORDER BY r.name",
                (id,),
            )
            .await?;
        let mut roles = Vec::new();
        while let Some(row) = rows.next().await? {
            roles.push(row.get(0)?);
        }

        Ok(Self {
            id,
            name,
            email,
            roles,
            token_hash,
        })
    }

    pub fn has_role(&self, role: &str) -> bool {
        self.roles.iter().any(|r| r == role)
    }

    pub fn is_admin(&self) -> bool {
        self.has_role(ADMIN_ROLE)
    }

    /// Fails with `Forbidden` unless this user is an admin.
    pub fn require_admin(&self) -> AppResult<()> {
        if self.is_admin() {
            Ok(())
        } else {
            Err(AppError::Forbidden)
        }
    }

    /// Fails with `Forbidden` unless this user is `user_id` or an admin.
    pub fn require_self_or_admin(&self, user_id: i64) -> AppResult<()> {
        if self.id == user_id || self.is_admin() {
            Ok(())
        } else {
            Err(AppError::Forbidden)
        }
    }

    /// Stored hash of the token this call was authenticated with.
    pub fn token_hash(&self) -> &str {
        &self.token_hash
    }
}

fn bearer_token(headers: &HeaderMap) -> Option<&str> {
    let value = headers.get(AUTHORIZATION)?.to_str().ok()?;
    let (scheme, token) = value.split_once(' ')?;
    let token = token.trim();
    (scheme.eq_ignore_ascii_case("bearer") && !token.is_empty()).then_some(token)
}