matrix accounts: server-side password login for swarm-controller

Extends PutMatrixAccountRequest with a mode field (token, the existing
behavior and default; or password). Password mode has swarm-controller
itself perform m.login.password against the caller-given homeserver
(mirrors hive-c0re's own /api/matrix-account-login for the hive-local
case) and stores the resulting token instead of a caller-supplied one
-- the password is used once, over this PUT, and never stored. Also
adds the 'main is reserved' guard hive-c0re's login form already has,
which swarm-controller had no equivalent of before this.

swarm-ui's link-matrix-account form gets a credential-mode toggle
wired to the same contract: token mode is unchanged, password mode
swaps the token field for user-id + password fields and makes
homeserver required (no hive-side fallback to resolve it against, per
PutMatrixAccountRequest::homeserver's own doc).

Per #4122.
This commit is contained in:
iris 2026-09-09 00:51:13 +02:00 committed by mara
commit 52446cb273
2 changed files with 263 additions and 45 deletions

View file

@ -10,11 +10,21 @@
//! ⚠️ Store first, notify second, and the order cannot be swapped: a notice
//! that overtakes its own write reaches a hive that reads nothing, and the
//! hive deliberately does not retry.
//!
//! Two credential modes, chosen by `PutMatrixAccountRequest::mode`: `token`
//! (default, back-compat with the original blind-store shape — the caller
//! already has a bearer token) and `password` (this daemon performs
//! `m.login.password` against the caller-given homeserver itself and stores
//! the resulting token; the password is never stored, and is not sent to the
//! hive either — only the derived token is). Mirrors what hive-c0re's own
//! `/api/matrix-account-login` does for a *hive-local* account, done here
//! instead so the browser never has to hold the password long enough to call
//! an arbitrary homeserver directly.
use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use swarm_queue_client::{CredentialNotice, credential_subject};
use swarm_secret_client::{SecretStore, matrix};
use utoipa::ToSchema;
@ -30,20 +40,54 @@ use super::{AppState, error_problem, swarm_hive};
/// (`allowed_common_names`), so the two are deliberately different strings.
const CERT_ROLE: &str = "swarm-controller";
fn default_mode() -> String {
"token".to_owned()
}
/// The credential to store for one agent's external matrix account.
#[derive(Debug, Deserialize, ToSchema)]
pub struct PutMatrixAccountRequest {
/// The access token. Never logged, and never returned by this route.
token: String,
/// `"token"` (default, back-compat) or `"password"`. Token mode stores
/// `token` as given; password mode logs into `homeserver` with `user_id`
/// + `password` and stores the token that comes back instead.
#[serde(default = "default_mode")]
#[schema(example = "token")]
mode: String,
/// The access token. Required (and used as given) in token mode; ignored
/// in password mode, where the token comes from the login instead. Never
/// logged, and never returned by this route.
token: Option<String>,
/// Password-mode only: the matrix user id (or bare localpart) to log in
/// as.
user_id: Option<String>,
/// Password-mode only. Never logged and never stored — only the token
/// `m.login.password` returns is.
password: Option<String>,
/// The account's homeserver, when it is not this swarm's own.
///
/// Stored beside the token rather than sent on the notice: a notice is a
/// queue message, so a homeserver carried there would exist only in
/// flight, with nowhere to reconstruct it from on a re-delivery.
///
/// Optional in token mode (omitted means "resolve to the agent's own
/// `hyperhive.matrix.url` on the hive side" — this route never needs to
/// know it itself for a blind store). **Required** in password mode:
/// logging in needs somewhere to log in against, and unlike token mode
/// there is no hive-side fallback to defer to.
#[schema(example = "https://matrix.example.org")]
homeserver: Option<String>,
}
/// `put_matrix_account`'s success body.
#[derive(Debug, Serialize, ToSchema)]
pub struct PutMatrixAccountResponse {
/// The account's matrix user id, recovered from the login response in
/// password mode. `None` in token mode — the caller already knows which
/// account their own token belongs to, and this route does not spend a
/// `whoami` round trip validating a token it was simply handed.
user_id: Option<String>,
}
/// Store an agent's external matrix account credential and notify its hive.
///
/// Idempotent: the store keeps versions, so repeating a call replaces the
@ -58,8 +102,8 @@ pub struct PutMatrixAccountRequest {
),
request_body = PutMatrixAccountRequest,
responses(
(status = 200, description = "stored, and the hive has been told"),
(status = 400, description = "a name is not an identifier, the account name is not a single path segment, or the hive is not in this swarm (problem+json)", body = String),
(status = 200, description = "stored, and the hive has been told", body = PutMatrixAccountResponse),
(status = 400, description = "a name is not an identifier, the account name is not a single path segment, the account is 'main' (reserved), the mode is unrecognized, a mode's required fields are missing, or the hive is not in this swarm (problem+json)", body = String),
(status = 503, description = "no swarm queue is wired up (problem+json)", body = String),
(status = 500, description = "the store write, the encode or the publish failed (problem+json)", body = String),
),
@ -69,7 +113,7 @@ pub async fn put_matrix_account(
State(state): State<AppState>,
axum::extract::Path((hive, agent, account)): axum::extract::Path<(String, String, String)>,
Json(req): Json<PutMatrixAccountRequest>,
) -> Result<StatusCode, problem_details::ProblemDetails> {
) -> Result<Json<PutMatrixAccountResponse>, problem_details::ProblemDetails> {
// The queue first, so a deployment that has none answers 503 whatever the
// caller spelled — and before the store is touched, so a request that
// could never be delivered does not leave a credential behind.
@ -88,6 +132,59 @@ pub async fn put_matrix_account(
// charset is its rule to state, not this handler's to restate.
let secret_path = matrix::account_path(&agent, &account)
.map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.to_string()))?;
// `main` is the hive-internal account `nix/agent-modules/matrix.nix`
// synthesizes per agent from `hyperhive.matrix.url` — the schema there
// forbids declaring a key by that name for the same reason this route
// refuses to write one: an extra account literally named `main` would
// not overwrite the real one (it lands at a different token-file suffix)
// but would confuse anything that lists accounts by name. Same guard
// hive-c0re's own `/api/matrix-account-login` applies, checked before any
// mode-specific work (including a network login) runs.
if account == "main" {
return Err(error_problem(
StatusCode::BAD_REQUEST,
"'main' is the hive-internal account, synthesized per agent from \
hyperhive.matrix.url it cannot be set through this route.",
));
}
// Resolve the mode to a concrete (token, homeserver, resolved user id) —
// password mode's network call happens here, before the store is
// touched, so a failed login leaves no partial state behind.
let (token, homeserver, user_id) = match req.mode.as_str() {
"token" => {
let Some(token) = req.token else {
return Err(error_problem(
StatusCode::BAD_REQUEST,
"token mode needs a token",
));
};
(token, req.homeserver, None)
}
"password" => {
let (Some(homeserver), Some(user_id), Some(password)) = (
req.homeserver.as_deref(),
req.user_id.as_deref(),
req.password.as_deref(),
) else {
return Err(error_problem(
StatusCode::BAD_REQUEST,
"password mode needs homeserver, user_id, and password",
));
};
let homeserver = homeserver.trim_end_matches('/').to_owned();
let (token, resolved_user_id) = matrix_password_login(&homeserver, user_id, password)
.await
.map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e))?;
(token, Some(homeserver), Some(resolved_user_id))
}
other => {
return Err(error_problem(
StatusCode::BAD_REQUEST,
&format!("unknown mode {other:?} (want token|password)"),
));
}
};
let store = SecretStore::from_env(CERT_ROLE).await.map_err(|e| {
tracing::warn!(error = %e, "connecting to the swarm secret store failed");
@ -97,8 +194,8 @@ pub async fn put_matrix_account(
.write(
&secret_path,
&matrix::Credential {
value: req.token,
homeserver: req.homeserver,
value: token,
homeserver,
},
)
.await
@ -140,5 +237,54 @@ pub async fn put_matrix_account(
})?;
tracing::info!(%subject, %hive, %agent, %account, "credential stored; hive notified");
Ok(StatusCode::OK)
Ok(Json(PutMatrixAccountResponse { user_id }))
}
/// POST `m.login.password` to `<homeserver>/_matrix/client/v3/login`.
/// Returns `(access_token, user_id)`.
///
/// Deliberately its own copy rather than a shared crate with
/// `hive-c0re::dashboard::matrix_accounts`'s near-identical helper: the two
/// log in on behalf of different callers (a hive's own dashboard vs. this
/// swarm-wide provisioning route) and share no other code — four lines of
/// JSON construction do not justify a dependency edge between them.
async fn matrix_password_login(
homeserver: &str,
user_id: &str,
password: &str,
) -> Result<(String, String), String> {
let url = format!("{homeserver}/_matrix/client/v3/login");
let body = serde_json::json!({
"type": "m.login.password",
"identifier": { "type": "m.id.user", "user": user_id },
"password": password,
"initial_device_display_name": "hyperhive",
});
let resp = reqwest::Client::new()
.post(&url)
.json(&body)
.send()
.await
.map_err(|e| format!("POST /login: {e}"))?;
let status = resp.status();
let json: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("parse /login response: {e}"))?;
if !status.is_success() {
let err = json
.get("error")
.and_then(serde_json::Value::as_str)
.unwrap_or("login failed");
return Err(format!("/login HTTP {status}: {err}"));
}
let token = json
.get("access_token")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| "login response missing access_token".to_owned())?;
let uid = json
.get("user_id")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| "login response missing user_id".to_owned())?;
Ok((token.to_owned(), uid.to_owned()))
}