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