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:
parent
6f4b3562b7
commit
d7e48091ca
6 changed files with 300 additions and 179 deletions
|
|
@ -462,7 +462,11 @@ pub async fn delete_matrix_account(
|
|||
let revoke = query
|
||||
.revoke
|
||||
.then_some(|homeserver: String, token: String| async move {
|
||||
crate::matrix_account::matrix_logout(&homeserver, &token).await
|
||||
let http = swarm_matrix_client::client().map_err(|e| format!("{e:#}"))?;
|
||||
let base = homeserver.trim_end_matches('/');
|
||||
swarm_matrix_client::logout(&http, base, &token)
|
||||
.await
|
||||
.map_err(|e| format!("{e:#}"))
|
||||
});
|
||||
remove_matrix(&store, &path, revoke).await.map_err(|e| {
|
||||
tracing::warn!(%hive, %agent, %account, error = ?e, "deleting the matrix account failed");
|
||||
|
|
|
|||
|
|
@ -249,8 +249,8 @@ fn token_credential(
|
|||
|
||||
/// Password mode's required fields, validated present, with the
|
||||
/// homeserver's trailing slash already trimmed — this is what actually
|
||||
/// reaches `matrix_password_login`, not the caller's raw string, so the
|
||||
/// login URL below never ends up with a doubled `//`.
|
||||
/// reaches [`swarm_matrix_client::password_login`], not the caller's raw
|
||||
/// string, so the login URL never ends up with a doubled `//`.
|
||||
struct PasswordFields<'a> {
|
||||
homeserver: String,
|
||||
user_id: &'a str,
|
||||
|
|
@ -295,10 +295,18 @@ async fn resolve_credential(
|
|||
"password" => {
|
||||
let fields = password_fields(req)
|
||||
.map_err(|e| Box::new(error_problem(StatusCode::BAD_REQUEST, e)))?;
|
||||
let (token, resolved_user_id) =
|
||||
matrix_password_login(&fields.homeserver, fields.user_id, fields.password)
|
||||
.await
|
||||
.map_err(|e| Box::new(error_problem(StatusCode::BAD_REQUEST, &e)))?;
|
||||
let refused = |e: anyhow::Error| {
|
||||
Box::new(error_problem(StatusCode::BAD_REQUEST, &format!("{e:#}")))
|
||||
};
|
||||
let http = swarm_matrix_client::client().map_err(refused)?;
|
||||
let (token, resolved_user_id) = swarm_matrix_client::password_login(
|
||||
&http,
|
||||
&fields.homeserver,
|
||||
fields.user_id,
|
||||
fields.password,
|
||||
)
|
||||
.await
|
||||
.map_err(refused)?;
|
||||
Ok((token, Some(fields.homeserver), Some(resolved_user_id)))
|
||||
}
|
||||
other => Err(Box::new(error_problem(
|
||||
|
|
@ -308,127 +316,6 @@ async fn resolve_credential(
|
|||
}
|
||||
}
|
||||
|
||||
/// Bound on reaching the homeserver.
|
||||
const HTTP_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
/// Bound on one whole homeserver round trip, body included. A password
|
||||
/// login makes the homeserver hash the password before it answers, so this
|
||||
/// is looser than a plain API call needs.
|
||||
const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
|
||||
/// A client with both homeserver bounds applied.
|
||||
fn http_client() -> Result<reqwest::Client, String> {
|
||||
reqwest::Client::builder()
|
||||
.connect_timeout(HTTP_CONNECT_TIMEOUT)
|
||||
.timeout(HTTP_TIMEOUT)
|
||||
.build()
|
||||
.map_err(|e| format!("build HTTP client: {e}"))
|
||||
}
|
||||
|
||||
/// `what` failed with `e`; a timeout names the bound that fired.
|
||||
fn http_error(what: &str, e: &reqwest::Error) -> String {
|
||||
if e.is_connect() && e.is_timeout() {
|
||||
format!("{what}: connect timed out after {HTTP_CONNECT_TIMEOUT:?}")
|
||||
} else if e.is_timeout() {
|
||||
format!("{what}: timed out after {HTTP_TIMEOUT:?}")
|
||||
} else {
|
||||
format!("{what}: {e}")
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 = http_client()?
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| http_error("POST /login", &e))?;
|
||||
let status = resp.status();
|
||||
let json: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| http_error("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()))
|
||||
}
|
||||
|
||||
/// POST `<homeserver>/_matrix/client/v3/logout` with `token`, which
|
||||
/// invalidates that token at the homeserver.
|
||||
///
|
||||
/// A 401 `M_UNKNOWN_TOKEN` means the homeserver already does not recognise
|
||||
/// the token — the state a logout wants — so that one answer counts as
|
||||
/// success rather than failure. Every other non-2xx is a failure.
|
||||
///
|
||||
/// The error names the status and the homeserver's `error` text, never the
|
||||
/// token.
|
||||
pub(crate) async fn matrix_logout(homeserver: &str, token: &str) -> Result<(), String> {
|
||||
let url = format!(
|
||||
"{}/_matrix/client/v3/logout",
|
||||
homeserver.trim_end_matches('/')
|
||||
);
|
||||
let resp = http_client()?
|
||||
.post(&url)
|
||||
.bearer_auth(token)
|
||||
.json(&serde_json::json!({}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| http_error("POST /logout", &e))?;
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
return Ok(());
|
||||
}
|
||||
let body = resp.json::<serde_json::Value>().await.ok();
|
||||
if status == StatusCode::UNAUTHORIZED
|
||||
&& body
|
||||
.as_ref()
|
||||
.and_then(|j| j.get("errcode"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
== Some("M_UNKNOWN_TOKEN")
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let err = body
|
||||
.and_then(|j| {
|
||||
j.get("error")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_owned)
|
||||
})
|
||||
.unwrap_or_else(|| "logout failed".to_owned());
|
||||
Err(format!("/logout HTTP {status}: {err}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
|
@ -436,7 +323,7 @@ mod tests {
|
|||
use super::super::linked_accounts::{Accounts, tests::FakeStore};
|
||||
use super::{
|
||||
PutMatrixAccountRequest, homeserver_or_configured_default, is_reserved_account,
|
||||
matrix_logout, password_fields, resolve_credential, token_credential,
|
||||
password_fields, resolve_credential, token_credential,
|
||||
};
|
||||
|
||||
fn request(mode: &str) -> PutMatrixAccountRequest {
|
||||
|
|
@ -696,46 +583,4 @@ mod tests {
|
|||
Some("https://matrix.example.org")
|
||||
);
|
||||
}
|
||||
|
||||
/// A stand-in homeserver on a loopback port, answering every
|
||||
/// `/_matrix/client/v3/logout` with `status` and `body`.
|
||||
async fn stub_homeserver(status: u16, body: &'static str) -> String {
|
||||
let app = axum::Router::new().route(
|
||||
"/_matrix/client/v3/logout",
|
||||
axum::routing::post(move || async move {
|
||||
(
|
||||
axum::http::StatusCode::from_u16(status).unwrap(),
|
||||
[(axum::http::header::CONTENT_TYPE, "application/json")],
|
||||
body,
|
||||
)
|
||||
}),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move { axum::serve(listener, app).await });
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
/// 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 homeserver = stub_homeserver(
|
||||
401,
|
||||
r#"{"errcode":"M_UNKNOWN_TOKEN","error":"Access token invalid"}"#,
|
||||
)
|
||||
.await;
|
||||
assert!(matrix_logout(&homeserver, "t0k3n").await.is_ok());
|
||||
}
|
||||
|
||||
/// The control: a 401 with a different errcode is still a failure, so
|
||||
/// the fold above is specific to `M_UNKNOWN_TOKEN` and not any 401.
|
||||
#[tokio::test]
|
||||
async fn logout_fails_on_other_401s() {
|
||||
let homeserver = stub_homeserver(401, r#"{"errcode":"M_FORBIDDEN","error":"nope"}"#).await;
|
||||
let err = matrix_logout(&homeserver, "t0k3n")
|
||||
.await
|
||||
.expect_err("not M_UNKNOWN_TOKEN");
|
||||
assert!(err.contains("401"), "{err}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue