Watch
0
0
Fork
You've already forked hyperhive
0

swarm-controller: log matrix accounts in and out through swarm-matrix-client

Adds password_login and logout to swarm-matrix-client and deletes the controller's private copies, with their own timeouts and error text.
This commit is contained in:
atlas 2026-10-17 16:45:55 +02:00 • committed by mara
commit d7e48091ca
6 changed files with 300 additions and 179 deletions

View file

@ -9,5 +9,10 @@ anyhow.workspace = true
reqwest.workspace = true
serde_json.workspace = true
[dev-dependencies]
# A loopback stand-in for the homeserver the login and logout tests call.
axum.workspace = true
tokio.workspace = true
[lints]
workspace = true

View file

@ -10,6 +10,11 @@ in to an existing one as the appservice, and ask a token who it is. Both mints
pin the device id `hyperhive-<localpart>`, so a second login **replaces** that
device's token instead of adding a device.
`swarm-controller` also logs an operator-linked account in with its password
(`password_login`) and invalidates its token (`logout`) on whatever homeserver
the operator names. Neither call is an appservice call, so neither carries the
appservice token.
## 🩸 A secret is a path, never a value
Every error here is built from the response's status and its `errcode`, never

View file

@ -1,7 +1,8 @@
//! The appservice calls against the swarm's homeserver: register, log in, ask
//! a token who it is, and the room calls `swarm-controller`'s admin-room
//! commands need (resolve an alias, list members, check an account exists,
//! send a message). Shared by `swarm-matrix-ctl` and `swarm-controller`;
//! send a message). Also the password login and logout `swarm-controller` runs
//! for an operator-linked account on any homeserver. Shared by `swarm-matrix-ctl` and `swarm-controller`;
//! see the README for why both mint through the same device id.
//!
//! 🩸 **Every error in this module is built from the response's `status` and
@ -16,6 +17,11 @@ use anyhow::{Context, Result, bail};
/// one.
const TIMEOUT_SECS: u64 = 10;
/// Bound on one password login, replacing [`TIMEOUT_SECS`] for that request.
/// A password login makes the homeserver hash the password before it answers,
/// so it is looser than a plain API call needs.
const PASSWORD_LOGIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
/// Bytes of the throwaway password `/register` is given.
///
/// Protocol overhead, and stored nowhere: this account authenticates by access
@ -144,6 +150,87 @@ pub async fn appservice_login(
access_token(&json)
}
/// Log in to **any** homeserver as `user` with `password`, and return the
/// fresh access token with the full user id it authenticates as.
///
/// For an account somewhere else that an operator hands over a password for.
/// The appservice authorises nothing here, so the request carries no bearer
/// token, and the device id is the homeserver's to choose.
///
/// # Errors
/// When the request cannot be sent, the response will not decode, the
/// homeserver refuses, or its answer carries no `access_token` or `user_id`.
pub async fn password_login(
client: &reqwest::Client,
base: &str,
user: &str,
password: &str,
) -> Result<(String, String)> {
let body = serde_json::json!({
"type": "m.login.password",
"identifier": { "type": "m.id.user", "user": user },
"password": password,
"initial_device_display_name": "hyperhive",
});
let (status, json) = exchange(password_login_request(client, base, &body))
.await
.context("POST /login with a password")?;
if !status.is_success() {
bail!("the homeserver refused to log in: {}", why(status, &json));
}
let user_id = json["user_id"]
.as_str()
.map(str::to_owned)
.context("the homeserver's login response carried no `user_id`")?;
Ok((access_token(&json)?, user_id))
}
/// The `/login` request of [`password_login`], carrying its own
/// [`PASSWORD_LOGIN_TIMEOUT`].
fn password_login_request(
client: &reqwest::Client,
base: &str,
body: &serde_json::Value,
) -> reqwest::RequestBuilder {
client
.post(format!("{base}/_matrix/client/v3/login"))
.json(body)
.timeout(PASSWORD_LOGIN_TIMEOUT)
}
/// Invalidate `token` at the homeserver.
///
/// `M_UNKNOWN_TOKEN` is success: the homeserver already does not recognise the
/// token, which is the state a logout wants.
///
/// # Errors
/// When the request cannot be sent, or the homeserver refuses with anything
/// other than `M_UNKNOWN_TOKEN`.
pub async fn logout(client: &reqwest::Client, base: &str, token: &str) -> Result<()> {
let resp = client
.post(format!("{base}/_matrix/client/v3/logout"))
.bearer_auth(token)
.json(&serde_json::json!({}))
.send()
.await
.context("POST /logout: sending the request")?;
let status = resp.status();
if status.is_success() {
return Ok(());
}
// A refusal from behind a proxy is not a matrix error object; its status
// is still worth reporting.
let json = resp
.json::<serde_json::Value>()
.await
.unwrap_or(serde_json::Value::Null);
if status == reqwest::StatusCode::UNAUTHORIZED && errcode(&json) == Some("M_UNKNOWN_TOKEN") {
return Ok(());
}
bail!("the homeserver refused to log out: {}", why(status, &json))
}
/// What the homeserver says a token is.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Whoami {
@ -390,13 +477,14 @@ async fn post(
as_token: &str,
body: &serde_json::Value,
) -> Result<(reqwest::StatusCode, serde_json::Value)> {
let resp = client
.post(url)
.bearer_auth(as_token)
.json(body)
.send()
.await
.context("sending the request")?;
exchange(client.post(url).bearer_auth(as_token).json(body)).await
}
/// Send `request`, returning the status beside the decoded body.
async fn exchange(
request: reqwest::RequestBuilder,
) -> Result<(reqwest::StatusCode, serde_json::Value)> {
let resp = request.send().await.context("sending the request")?;
let status = resp.status();
let json = resp
.json::<serde_json::Value>()
@ -462,6 +550,22 @@ pub fn random_hex(bytes: usize) -> Result<String> {
mod tests {
use super::*;
#[test]
fn a_password_login_is_bounded_at_30s_and_a_logout_at_the_clients_10s() {
let client = client().expect("client");
let login = password_login_request(&client, "http://hs.invalid", &serde_json::json!({}))
.build()
.expect("login request");
assert_eq!(login.timeout(), Some(&std::time::Duration::from_secs(30)));
// Control: a request that sets no timeout of its own reads as `None`,
// so the bound above is the login's, not a default every request has.
let logout = client
.post("http://hs.invalid/_matrix/client/v3/logout")
.build()
.expect("logout request");
assert_eq!(logout.timeout(), None);
}
#[test]
fn both_calls_pin_the_same_device_so_a_relogin_replaces_it() {
// The whole reason the recovery arm is safe to take repeatedly: an
@ -622,4 +726,160 @@ mod tests {
assert_eq!(pw.len(), PASSWORD_BYTES * 2);
assert!(pw.bytes().all(|b| b.is_ascii_hexdigit()), "not hex");
}
/// The `Authorization` header and JSON body of a request.
type Request = (Option<String>, serde_json::Value);
/// What a stub homeserver saw of its one request.
#[derive(Default, Clone)]
struct Seen(std::sync::Arc<std::sync::Mutex<Option<Request>>>);
impl Seen {
fn take(&self) -> Request {
self.0
.lock()
.expect("not poisoned")
.take()
.expect("the stub saw a request")
}
}
/// A homeserver on a loopback port that answers `path` with `status` and
/// `body`, and records the request's `Authorization` header and JSON body.
async fn stub(path: &'static str, status: u16, body: &'static str) -> (String, Seen) {
let seen = Seen::default();
let recorder = seen.clone();
let app = axum::Router::new().route(
path,
axum::routing::post(
move |headers: axum::http::HeaderMap,
axum::Json(sent): axum::Json<serde_json::Value>| async move {
let auth = headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
*recorder.0.lock().expect("not poisoned") = Some((auth, sent));
(
axum::http::StatusCode::from_u16(status).expect("a status"),
[(axum::http::header::CONTENT_TYPE, "application/json")],
body,
)
},
),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("a loopback port");
let addr = listener.local_addr().expect("a bound address");
tokio::spawn(async move { axum::serve(listener, app).await });
(format!("http://{addr}"), seen)
}
const LOGIN: &str = "/_matrix/client/v3/login";
const LOGOUT: &str = "/_matrix/client/v3/logout";
#[tokio::test]
async fn a_password_login_sends_the_password_unauthenticated_and_reads_both_fields() {
let (base, seen) = stub(
LOGIN,
200,
r#"{"access_token":"syt_new","user_id":"@a:t.local"}"#,
)
.await;
let (token, user_id) = password_login(&client().expect("a client"), &base, "a", "hunter2")
.await
.expect("the stub accepts");
assert_eq!(token, "syt_new");
assert_eq!(user_id, "@a:t.local");
let (auth, sent) = seen.take();
assert_eq!(auth, None, "a password login carries no bearer token");
assert_eq!(
sent,
serde_json::json!({
"type": "m.login.password",
"identifier": { "type": "m.id.user", "user": "a" },
"password": "hunter2",
"initial_device_display_name": "hyperhive",
})
);
}
#[tokio::test]
async fn a_refused_password_login_is_described_by_its_errcode_only() {
let (base, _) = stub(
LOGIN,
403,
r#"{"errcode":"M_FORBIDDEN","error":"free-form text","access_token":"syt_leak"}"#,
)
.await;
let e = password_login(&client().expect("a client"), &base, "a", "hunter2")
.await
.expect_err("the stub refuses");
let message = format!("{e:#}");
assert!(message.contains("403"), "{message}");
assert!(message.contains("M_FORBIDDEN"), "{message}");
assert!(!message.contains("free-form"), "{message}");
assert!(!message.contains("syt_leak"), "{message}");
}
#[tokio::test]
async fn a_login_answer_missing_a_field_is_an_error_naming_the_field() {
let (base, _) = stub(LOGIN, 200, r#"{"access_token":"syt_new"}"#).await;
let e = password_login(&client().expect("a client"), &base, "a", "hunter2")
.await
.expect_err("no user_id");
let message = format!("{e:#}");
assert!(message.contains("user_id"), "{message}");
assert!(!message.contains("syt_new"), "{message}");
let (base, _) = stub(LOGIN, 200, r#"{"user_id":"@a:t.local"}"#).await;
let e = password_login(&client().expect("a client"), &base, "a", "hunter2")
.await
.expect_err("no access_token");
assert!(format!("{e:#}").contains("access_token"), "{e:#}");
}
#[tokio::test]
async fn a_logout_presents_the_token_it_invalidates() {
let (base, seen) = stub(LOGOUT, 200, "{}").await;
logout(&client().expect("a client"), &base, "t0k3n")
.await
.expect("the stub accepts");
let (auth, sent) = seen.take();
assert_eq!(auth.as_deref(), Some("Bearer t0k3n"));
assert_eq!(sent, serde_json::json!({}));
}
/// A revoke whose token the homeserver already does not recognise has
/// reached logout's end state, not failed it.
#[tokio::test]
async fn logout_treats_401_m_unknown_token_as_already_revoked() {
let (base, _) = stub(
LOGOUT,
401,
r#"{"errcode":"M_UNKNOWN_TOKEN","error":"Access token invalid"}"#,
)
.await;
logout(&client().expect("a client"), &base, "t0k3n")
.await
.expect("already revoked");
}
/// The control: a 401 with a different errcode, and a non-JSON refusal
/// from a proxy, are still failures, so the fold above is specific to
/// `M_UNKNOWN_TOKEN`.
#[tokio::test]
async fn logout_fails_on_any_other_refusal() {
for (status, body) in [
(401, r#"{"errcode":"M_FORBIDDEN","error":"nope"}"#),
(403, r#"{"errcode":"M_UNKNOWN_TOKEN"}"#),
(502, "<html>bad gateway</html>"),
] {
let (base, _) = stub(LOGOUT, status, body).await;
let e = logout(&client().expect("a client"), &base, "t0k3n")
.await
.expect_err("not M_UNKNOWN_TOKEN on a 401");
assert!(format!("{e:#}").contains(&status.to_string()), "{e:#}");
}
}
}