diff --git a/Cargo.lock b/Cargo.lock index f68047db..16ddc9ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4933,8 +4933,10 @@ name = "swarm-matrix-client" version = "0.1.0" dependencies = [ "anyhow", + "axum", "reqwest", "serde_json", + "tokio", ] [[package]] diff --git a/swarm-controller/src/linked_accounts.rs b/swarm-controller/src/linked_accounts.rs index 10c05544..9650a657 100644 --- a/swarm-controller/src/linked_accounts.rs +++ b/swarm-controller/src/linked_accounts.rs @@ -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"); diff --git a/swarm-controller/src/matrix_account.rs b/swarm-controller/src/matrix_account.rs index 955f3933..5d3d6f22 100644 --- a/swarm-controller/src/matrix_account.rs +++ b/swarm-controller/src/matrix_account.rs @@ -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::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 `/_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 `/_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::().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}"); - } } diff --git a/swarm-matrix-client/Cargo.toml b/swarm-matrix-client/Cargo.toml index 4d8bd38b..8a9748ba 100644 --- a/swarm-matrix-client/Cargo.toml +++ b/swarm-matrix-client/Cargo.toml @@ -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 diff --git a/swarm-matrix-client/README.md b/swarm-matrix-client/README.md index 36a344bd..d7ce5ecd 100644 --- a/swarm-matrix-client/README.md +++ b/swarm-matrix-client/README.md @@ -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-`, 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 diff --git a/swarm-matrix-client/src/lib.rs b/swarm-matrix-client/src/lib.rs index fb1d1f35..ecc3ff68 100644 --- a/swarm-matrix-client/src/lib.rs +++ b/swarm-matrix-client/src/lib.rs @@ -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::() + .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::() @@ -462,6 +550,22 @@ pub fn random_hex(bytes: usize) -> Result { 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, serde_json::Value); + + /// What a stub homeserver saw of its one request. + #[derive(Default, Clone)] + struct Seen(std::sync::Arc>>); + + 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| 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, "bad gateway"), + ] { + 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:#}"); + } + } }