slave 0.13.1

CLI for slave, a Laravel-inspired Rust framework for MCP-first apps: scaffold an MCP backend with auth, roles, teams and SurrealDB migrations, add server-rendered web apps, and route them by domain or path
//! MCP tools for signing in (by email code or passkey) and the signed-in user.

use axum::extract::FromRef;
use axum::http::request::Parts;
use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::handler::server::tool::Extension;
use rmcp::handler::server::wrapper::{Json, Parameters};
use rmcp::{tool, tool_router};
use slave_framework::auth::{Challenge, normalize_email};

use crate::base::auth::bearer_token;
use crate::base::error::{AppError, AppResult};
use crate::base::mcp::McpServer;
use crate::modules::auth::AuthService;
use crate::modules::auth::dto::auth::{
    ChallengeResponse, DoneResponse, EmailRequest, MeResponse, PasskeyRequest, SessionResponse,
    VerifyRequest,
};

pub fn tools() -> ToolRouter<McpServer> {
    McpServer::auth_tools()
}

#[tool_router(router = auth_tools, vis = "pub(crate)")]
impl McpServer {
    /// The signed-in user and their roles. Sign in with `auth_email_start`
    /// and `auth_email_verify` (or a passkey) and send the token as
    /// `Authorization: Bearer <token>`.
    #[tool(annotations(read_only_hint = true, open_world_hint = false))]
    async fn auth_me(&self, Extension(parts): Extension<Parts>) -> AppResult<Json<MeResponse>> {
        let user = self.authenticate_customer(&parts).await?;
        Ok(Json(MeResponse {
            id: user.id,
            name: user.name,
            email: user.email,
            roles: user.roles,
        }))
    }

    /// Email a 6-digit sign-in code to `email`. New addresses get an account
    /// when they verify it; the first user ever becomes super admin.
    #[tool(annotations(open_world_hint = true))]
    async fn auth_email_start(
        &self,
        Parameters(input): Parameters<EmailRequest>,
    ) -> AppResult<Json<DoneResponse>> {
        let email = email(&input.email)?;
        self.state.auth.send_code(&email).await?;
        Ok(Json(DoneResponse { ok: true }))
    }

    /// Sign in with the code from `auth_email_start`; returns a session token.
    #[tool(annotations(open_world_hint = false))]
    async fn auth_email_verify(
        &self,
        Parameters(input): Parameters<VerifyRequest>,
    ) -> AppResult<Json<SessionResponse>> {
        let email = email(&input.email)?;
        if !self.state.auth.verify_code(&email, &input.code).await? {
            return Err(AppError::Validation(
                "That code is wrong or has expired.".into(),
            ));
        }
        let user = AuthService::from_ref(&self.state).sign_in(&email).await?;
        let token = self
            .state
            .auth
            .create_session(user.id, input.customer)
            .await?;
        Ok(Json(SessionResponse { token }))
    }

    /// Sign out: ends the caller's session.
    #[tool(annotations(destructive_hint = true, open_world_hint = false))]
    async fn auth_logout(
        &self,
        Extension(parts): Extension<Parts>,
    ) -> AppResult<Json<DoneResponse>> {
        let token = bearer_token(&parts.headers).ok_or(AppError::Unauthorized)?;
        self.state.auth.end_session(token).await?;
        Ok(Json(DoneResponse { ok: true }))
    }

    /// Start adding a passkey for the signed-in user.
    #[tool(annotations(open_world_hint = false))]
    async fn auth_passkey_register_start(
        &self,
        Extension(parts): Extension<Parts>,
    ) -> AppResult<Json<ChallengeResponse>> {
        let user = self.authenticate_customer(&parts).await?;
        let email = user.email.clone().unwrap_or_else(|| user.name.clone());
        let challenge = self
            .state
            .auth
            .passkey_register_start(user.id, &email)
            .await?;
        Ok(Json(challenge.into()))
    }

    /// Finish adding a passkey.
    #[tool(annotations(open_world_hint = false))]
    async fn auth_passkey_register_finish(
        &self,
        Extension(parts): Extension<Parts>,
        Parameters(input): Parameters<PasskeyRequest>,
    ) -> AppResult<Json<DoneResponse>> {
        let user = self.authenticate_customer(&parts).await?;
        self.state
            .auth
            .passkey_register_finish(user.id, &input.id, input.credential)
            .await?;
        Ok(Json(DoneResponse { ok: true }))
    }

    /// Start signing in with a passkey.
    #[tool(annotations(open_world_hint = false))]
    async fn auth_passkey_login_start(&self) -> AppResult<Json<ChallengeResponse>> {
        Ok(Json(self.state.auth.passkey_login_start().await?.into()))
    }

    /// Finish signing in with a passkey; returns a session token.
    #[tool(annotations(open_world_hint = false))]
    async fn auth_passkey_login_finish(
        &self,
        Parameters(input): Parameters<PasskeyRequest>,
    ) -> AppResult<Json<SessionResponse>> {
        let user_id = self
            .state
            .auth
            .passkey_login_finish(&input.id, input.credential)
            .await?;
        AuthService::from_ref(&self.state).load(user_id).await?;
        let token = self
            .state
            .auth
            .create_session(user_id, input.customer)
            .await?;
        Ok(Json(SessionResponse { token }))
    }
}

fn email(email: &str) -> AppResult<String> {
    normalize_email(email)
        .ok_or_else(|| AppError::Validation("That email address is not valid.".into()))
}

impl From<Challenge> for ChallengeResponse {
    fn from(challenge: Challenge) -> Self {
        Self {
            id: challenge.id,
            options: challenge.options,
        }
    }
}