slave 0.7.0

CLI for slave, a Laravel-inspired Rust framework for MCP-first apps: scaffold an MCP backend with auth, roles and Turso migrations, add server-rendered web apps, and route them by domain or path
//! Pages of the `{{ app }}` app. Handlers gather data and render a view from
//! `templates/` (see `views.rs`); the HTML lives in the templates.
//!
//! Sign-in goes through the OpenID Connect provider (Authorization Code flow
//! with PKCE): `/login` redirects there, `/callback` receives the result and
//! keeps the verified ID token in an HttpOnly cookie, which is sent to the MCP
//! backend as the bearer token. This app never sees passwords.

use axum::Router;
use axum::extract::{Query, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{AppendHeaders, Html, IntoResponse, Redirect, Response};
use axum::routing::{get, post};
use serde::Deserialize;
use serde::Serialize;
use serde_json::json;
use slave_framework::oidc::PendingSignIn;

use crate::views::Views;
use crate::{AppState, TITLE};

/// Cookie holding the signed-in user's ID token (the MCP bearer token).
const TOKEN_COOKIE: &str = "slave_token";
/// Cookie holding a sign-in in progress (state, PKCE verifier, nonce).
const SIGN_IN_COOKIE: &str = "slave_sign_in";

/// The app's pages, under its base path (see [`AppState::url`]).
pub fn router(state: AppState) -> Router {
    let mut router = Router::new()
        .route(&state.url("/"), get(home))
        .route(&state.url("/login"), get(login))
        .route(&state.url("/callback"), get(callback))
        .route(&state.url("/logout"), post(logout))
        .route("/health", get(health));
    if !state.base.is_empty() {
        // `/admin/` as well as `/admin`.
        router = router.route(&format!("{}/", state.base), get(home));
    }
    router.with_state(state)
}

/// Result of the backend's `auth_me` tool.
#[derive(Deserialize)]
struct Me {
    name: String,
    email: Option<String>,
    roles: Vec<String>,
}

/// What the provider sends back to `/callback`.
#[derive(Deserialize)]
struct Callback {
    code: Option<String>,
    state: Option<String>,
    error: Option<String>,
    error_description: Option<String>,
}

/// What every view receives: the app's name and the page title, plus the
/// page's own data (flattened).
#[derive(Serialize)]
struct Page<'a, T: Serialize> {
    app: &'static str,
    title: &'a str,
    #[serde(flatten)]
    data: T,
}

#[derive(Serialize)]
struct Param {
    name: String,
    value: String,
}

#[derive(Serialize)]
struct UserView {
    name: String,
    email: Option<String>,
    roles: String,
}

/// Data of the `home` view.
#[derive(Serialize)]
struct Home {
    /// Domain parameters from the gateway, e.g. `{toko}` in `{toko}.localhost`.
    params: Vec<Param>,
    user: Option<UserView>,
    error: Option<String>,
    login_url: String,
    logout_url: String,
}

/// Data of the `message` view.
#[derive(Serialize)]
struct Message {
    message: String,
    login_url: String,
}

/// Renders view `name`. Debug builds reload the templates on every request,
/// so edits show without restarting, and show template errors in the page.
fn render<T: Serialize>(
    app: &AppState,
    status: StatusCode,
    name: &str,
    title: &str,
    data: T,
) -> Response {
    let page = Page {
        app: TITLE,
        title,
        data,
    };
    let rendered = if cfg!(debug_assertions) {
        Views::load(&app.templates_dir).and_then(|views| views.render(name, &page))
    } else {
        app.views.render(name, &page)
    };
    match rendered {
        Ok(html) => (status, Html(html)).into_response(),
        Err(err) => {
            tracing::error!("view `{name}`: {err}");
            let detail = if cfg!(debug_assertions) {
                err
            } else {
                String::new()
            };
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                format!("template error {detail}"),
            )
                .into_response()
        }
    }
}

async fn home(State(app): State<AppState>, headers: HeaderMap) -> Response {
    let params = route_params(&headers)
        .into_iter()
        .map(|(name, value)| Param { name, value })
        .collect();
    let me = match token(&headers) {
        Some(token) => Some(app.mcp.call::<Me>("auth_me", json!({}), Some(&token)).await),
        None => None,
    };
    let (user, error) = match me {
        Some(Ok(me)) => (
            Some(UserView {
                name: me.name,
                email: me.email,
                roles: me.roles.join(", "),
            }),
            None,
        ),
        Some(Err(err)) if err.code != "unauthenticated" => (None, Some(err.message)),
        _ => (None, None),
    };
    let data = Home {
        params,
        user,
        error,
        login_url: app.url("/login"),
        logout_url: app.url("/logout"),
    };
    render(&app, StatusCode::OK, "home", "Home", data)
}

/// Starts signing in: redirects to the provider.
async fn login(State(app): State<AppState>, headers: HeaderMap) -> Response {
    let client = match app.oidc().await {
        Ok(client) => client,
        Err(message) => return problem(&app, StatusCode::SERVICE_UNAVAILABLE, &message),
    };
    let (url, pending) = match client.authorize_url(&callback_url(&app, &headers)) {
        Ok(started) => started,
        Err(err) => return problem(&app, StatusCode::INTERNAL_SERVER_ERROR, &err.to_string()),
    };
    let cookie = format!(
        "{SIGN_IN_COOKIE}={}; Path={}; HttpOnly; SameSite=Lax; Max-Age=600",
        pending.encode(),
        app.url("/")
    );
    ([(header::SET_COOKIE, cookie)], Redirect::to(&url)).into_response()
}

/// Finishes signing in: verifies the provider's answer and keeps the ID token.
async fn callback(
    State(app): State<AppState>,
    headers: HeaderMap,
    Query(answer): Query<Callback>,
) -> Response {
    if let Some(error) = answer.error {
        let reason = answer.error_description.unwrap_or(error);
        return problem(
            &app,
            StatusCode::UNAUTHORIZED,
            &format!("Sign-in failed: {reason}"),
        );
    }
    let (Some(code), Some(state)) = (answer.code, answer.state) else {
        return problem(
            &app,
            StatusCode::BAD_REQUEST,
            "The sign-in response is incomplete.",
        );
    };
    let Some(pending) = cookie(&headers, SIGN_IN_COOKIE).and_then(|v| PendingSignIn::decode(&v))
    else {
        return problem(
            &app,
            StatusCode::BAD_REQUEST,
            "This sign-in expired; please try again.",
        );
    };
    let client = match app.oidc().await {
        Ok(client) => client,
        Err(message) => return problem(&app, StatusCode::SERVICE_UNAVAILABLE, &message),
    };
    let id_token = match client
        .exchange(&callback_url(&app, &headers), &code, &state, &pending)
        .await
    {
        Ok(id_token) => id_token,
        Err(err) => {
            tracing::warn!("sign-in failed: {err}");
            return problem(
                &app,
                StatusCode::UNAUTHORIZED,
                "Sign-in failed; please try again.",
            );
        }
    };
    let path = app.url("/");
    (
        AppendHeaders([
            (
                header::SET_COOKIE,
                format!("{TOKEN_COOKIE}={id_token}; Path={path}; HttpOnly; SameSite=Lax"),
            ),
            (
                header::SET_COOKIE,
                format!("{SIGN_IN_COOKIE}=; Path={path}; HttpOnly; SameSite=Lax; Max-Age=0"),
            ),
        ]),
        Redirect::to(&path),
    )
        .into_response()
}

/// Signs out of this app (the provider's own session is left alone).
async fn logout(State(app): State<AppState>) -> Response {
    let cookie = format!(
        "{TOKEN_COOKIE}=; Path={}; HttpOnly; SameSite=Lax; Max-Age=0",
        app.url("/")
    );
    ([(header::SET_COOKIE, cookie)], Redirect::to(&app.url("/"))).into_response()
}

/// Where the provider sends the browser back: `<public URL><base>/callback`.
/// Register it at the provider as this app's redirect URI.
fn callback_url(app: &AppState, headers: &HeaderMap) -> String {
    let origin = app.sign_in.public_url.clone().unwrap_or_else(|| {
        let header = |name: &str| headers.get(name).and_then(|v| v.to_str().ok());
        let scheme = header("x-forwarded-proto").unwrap_or("http");
        let host = header("x-forwarded-host")
            .or_else(|| header("host"))
            .unwrap_or("127.0.0.1");
        format!("{scheme}://{host}")
    });
    format!("{}{}", origin.trim_end_matches('/'), app.url("/callback"))
}

fn problem(app: &AppState, status: StatusCode, message: &str) -> Response {
    let data = Message {
        message: message.to_owned(),
        login_url: app.url("/login"),
    };
    render(app, status, "message", "Sign in", data)
}

/// Liveness probe for the gateway and orchestrators.
async fn health() -> &'static str {
    "ok"
}

/// The bearer token (ID token) from the session cookie, if any.
fn token(headers: &HeaderMap) -> Option<String> {
    cookie(headers, TOKEN_COOKIE)
}

fn cookie(headers: &HeaderMap, name: &str) -> Option<String> {
    headers
        .get_all(header::COOKIE)
        .iter()
        .filter_map(|value| value.to_str().ok())
        .flat_map(|value| value.split(';'))
        .filter_map(|pair| pair.trim().split_once('='))
        .find(|(key, value)| *key == name && !value.is_empty())
        .map(|(_, value)| value.to_owned())
}

/// `(name, value)` for every `X-Slave-Param-<Name>` header set by the gateway.
fn route_params(headers: &HeaderMap) -> Vec<(String, String)> {
    let mut params: Vec<(String, String)> = headers
        .iter()
        .filter_map(|(name, value)| {
            let name = name.as_str().strip_prefix("x-slave-param-")?;
            Some((name.to_owned(), value.to_str().ok()?.to_owned()))
        })
        .collect();
    params.sort();
    params
}