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
//! Signing in: a 6-digit code sent to the user's email, or a passkey. The
//! MCP backend checks them (the `auth_*` tools) and returns a session token,
//! kept here in an HttpOnly cookie and sent to the backend as the bearer
//! token. There are no passwords.
//!
//! Routes: `GET`/`POST /login` (the `login` view; posting an email sends a
//! code), `POST /login/verify`, `POST /logout`, `POST /passkey/<step>` (for
//! `sign-in.js`, which drives the browser's passkey prompts from buttons with
//! `data-passkey="login"` or `"register"`).

use axum::extract::{Path, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{AppendHeaders, IntoResponse, Redirect, Response};
use axum::routing::{get, post};
use axum::{Form, Json, Router};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};

use crate::mcp::ToolError;
use crate::{AppState, CUSTOMER};

/// Cookie holding the session token (the MCP bearer token).
const TOKEN_COOKIE: &str = "slave_token";

const SCRIPT: &str = r#"// Passkeys: buttons with data-passkey="login" or "register" and data-url.
document.addEventListener("click", async (event) => {
  const button = event.target.closest("[data-passkey]");
  if (!button) return;
  event.preventDefault();
  const kind = button.dataset.passkey;
  const post = async (step, body) => {
    const response = await fetch(`${button.dataset.url}/${kind}-${step}`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(body || {}),
    });
    if (!response.ok) throw new Error(await response.text());
    return response.json();
  };
  try {
    const { id, options } = await post("start");
    const credential = kind === "login"
      ? await navigator.credentials.get({ publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options) })
      : await navigator.credentials.create({ publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(options) });
    await post("finish", { id, credential: credential.toJSON() });
    if (kind === "login") location.href = button.dataset.next || "/";
    else button.replaceWith("Passkey added.");
  } catch (error) {
    if (error.name !== "NotAllowedError") alert(error.message);
  }
});
"#;

/// Data of the `login` view.
#[derive(Serialize)]
pub struct Login {
    pub email: String,
    /// The code was sent: show the code field.
    pub code_sent: bool,
    pub error: Option<String>,
    pub login_url: String,
    pub verify_url: String,
    pub passkey_url: String,
    pub script_url: String,
    pub home_url: String,
}

impl Login {
    pub fn new(app: &AppState) -> Self {
        Self {
            email: String::new(),
            code_sent: false,
            error: None,
            login_url: app.url("/login"),
            verify_url: app.url("/login/verify"),
            passkey_url: app.url("/passkey"),
            script_url: app.url("/sign-in.js"),
            home_url: app.url("/"),
        }
    }
}

pub fn routes(state: AppState) -> Router {
    Router::new()
        .route(&state.url("/login"), get(login_page).post(send_code))
        .route(&state.url("/login/verify"), post(verify))
        .route(&state.url("/logout"), post(logout))
        .route(&state.url("/passkey/{step}"), post(passkey))
        .route(&state.url("/sign-in.js"), get(script))
        .with_state(state)
}

/// The signed-in user's session token, if any.
pub fn token(headers: &HeaderMap) -> Option<String> {
    headers
        .get_all(header::COOKIE)
        .iter()
        .filter_map(|value| value.to_str().ok())
        .flat_map(|value| value.split(';'))
        .find_map(|pair| {
            let (key, value) = pair.trim().split_once('=')?;
            (key == TOKEN_COOKIE && !value.is_empty()).then(|| value.to_owned())
        })
}

/// The `login` view.
pub fn page(app: &AppState, login: Login) -> Response {
    crate::pages::render(app, StatusCode::OK, "login", "Sign in", login)
}

async fn login_page(State(app): State<AppState>) -> Response {
    page(&app, Login::new(&app))
}

#[derive(Deserialize)]
struct EmailForm {
    email: String,
    #[serde(default)]
    code: String,
}

/// Sends a code to the posted email, then asks for it.
async fn send_code(State(app): State<AppState>, Form(form): Form<EmailForm>) -> Response {
    let sent = app
        .mcp
        .call::<Value>("auth_email_start", json!({ "email": form.email }), None)
        .await;
    let mut login = Login::new(&app);
    login.email = form.email;
    match sent {
        Ok(_) => login.code_sent = true,
        Err(err) => login.error = Some(err.message),
    }
    page(&app, login)
}

/// Checks the code and signs in.
async fn verify(
    State(app): State<AppState>,
    headers: HeaderMap,
    Form(form): Form<EmailForm>,
) -> Response {
    let session = app
        .mcp
        .call::<Value>(
            "auth_email_verify",
            json!({ "email": form.email, "code": form.code, "customer": CUSTOMER }),
            None,
        )
        .await;
    match signed_in(&app, &headers, session).await {
        Ok(cookie) => (
            AppendHeaders([(header::SET_COOKIE, cookie)]),
            Redirect::to(&app.url("/")),
        )
            .into_response(),
        Err(err) => {
            let mut login = Login::new(&app);
            login.email = form.email;
            login.code_sent = true;
            login.error = Some(err.message);
            page(&app, login)
        }
    }
}

/// Signs out: ends the session at the backend and drops the cookie.
async fn logout(State(app): State<AppState>, headers: HeaderMap) -> Response {
    if let Some(token) = token(&headers) {
        let _ = app
            .mcp
            .call::<Value>("auth_logout", json!({}), Some(&token))
            .await;
    }
    let flags = app.cookie_flags(&headers);
    let cookie = format!("{TOKEN_COOKIE}=; Path={}; {flags}; Max-Age=0", app.url("/"));
    ([(header::SET_COOKIE, cookie)], Redirect::to(&app.url("/"))).into_response()
}

/// One step of a passkey ceremony for `sign-in.js`.
async fn passkey(
    State(app): State<AppState>,
    Path(step): Path<String>,
    headers: HeaderMap,
    Json(mut body): Json<Value>,
) -> Response {
    let token = token(&headers);
    let (tool, token, arguments) = match step.as_str() {
        "login-start" => ("auth_passkey_login_start", None, json!({})),
        "login-finish" => (
            "auth_passkey_login_finish",
            None,
            json!({ "id": body["id"].take(), "credential": body["credential"].take(), "customer": CUSTOMER }),
        ),
        "register-start" => ("auth_passkey_register_start", token, json!({})),
        "register-finish" => (
            "auth_passkey_register_finish",
            token,
            json!({ "id": body["id"].take(), "credential": body["credential"].take() }),
        ),
        _ => return StatusCode::NOT_FOUND.into_response(),
    };
    let result = app
        .mcp
        .call::<Value>(tool, arguments, token.as_deref())
        .await;
    if step == "login-finish" {
        return match signed_in(&app, &headers, result).await {
            Ok(cookie) => {
                ([(header::SET_COOKIE, cookie)], Json(json!({ "ok": true }))).into_response()
            }
            Err(err) => (StatusCode::BAD_REQUEST, err.message).into_response(),
        };
    }
    match result {
        Ok(value) => Json(value).into_response(),
        Err(err) => (StatusCode::BAD_REQUEST, err.message).into_response(),
    }
}

async fn script() -> Response {
    ([(header::CONTENT_TYPE, "text/javascript")], SCRIPT).into_response()
}

/// The session cookie for a new session; in a customer-facing app the user
/// also becomes a customer of the store first.
async fn signed_in(
    app: &AppState,
    headers: &HeaderMap,
    session: Result<Value, ToolError>,
) -> Result<String, ToolError> {
    let session = session?;
    let token = session["token"].as_str().unwrap_or_default();
    if CUSTOMER {
        let slug = store_slug(headers)
            .ok_or_else(|| ToolError::new("not_found", "This store does not exist."))?;
        app.mcp
            .call::<Value>("customer_join", json!({ "slug": slug }), Some(token))
            .await?;
    }
    let flags = app.cookie_flags(headers);
    Ok(format!(
        "{TOKEN_COOKIE}={token}; Path={}; {flags}",
        app.url("/")
    ))
}

/// The store (a team slug) this request is for: the first domain parameter,
/// e.g. `{toko}` in `{toko}.example.com`, or `{{ env }}_STORE` for one store.
fn store_slug(headers: &HeaderMap) -> Option<String> {
    headers
        .iter()
        .filter(|(name, _)| name.as_str().starts_with("x-slave-param-"))
        .find_map(|(_, value)| value.to_str().ok().map(str::to_owned))
        .or_else(|| std::env::var("{{ env }}_STORE").ok())
        .filter(|slug| !slug.is_empty())
}