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:
parent
cc8fb0ee44
commit
52446cb273
2 changed files with 263 additions and 45 deletions
|
|
@ -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()))
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue