//! 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
}