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
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -4933,8 +4933,10 @@ name = "swarm-matrix-client"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"axum",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -462,7 +462,11 @@ pub async fn delete_matrix_account(
|
||||||
let revoke = query
|
let revoke = query
|
||||||
.revoke
|
.revoke
|
||||||
.then_some(|homeserver: String, token: String| async move {
|
.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| {
|
remove_matrix(&store, &path, revoke).await.map_err(|e| {
|
||||||
tracing::warn!(%hive, %agent, %account, error = ?e, "deleting the matrix account failed");
|
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
|
/// Password mode's required fields, validated present, with the
|
||||||
/// homeserver's trailing slash already trimmed — this is what actually
|
/// homeserver's trailing slash already trimmed — this is what actually
|
||||||
/// reaches `matrix_password_login`, not the caller's raw string, so the
|
/// reaches [`swarm_matrix_client::password_login`], not the caller's raw
|
||||||
/// login URL below never ends up with a doubled `//`.
|
/// string, so the login URL never ends up with a doubled `//`.
|
||||||
struct PasswordFields<'a> {
|
struct PasswordFields<'a> {
|
||||||
homeserver: String,
|
homeserver: String,
|
||||||
user_id: &'a str,
|
user_id: &'a str,
|
||||||
|
|
@ -295,10 +295,18 @@ async fn resolve_credential(
|
||||||
"password" => {
|
"password" => {
|
||||||
let fields = password_fields(req)
|
let fields = password_fields(req)
|
||||||
.map_err(|e| Box::new(error_problem(StatusCode::BAD_REQUEST, e)))?;
|
.map_err(|e| Box::new(error_problem(StatusCode::BAD_REQUEST, e)))?;
|
||||||
let (token, resolved_user_id) =
|
let refused = |e: anyhow::Error| {
|
||||||
matrix_password_login(&fields.homeserver, fields.user_id, fields.password)
|
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
|
.await
|
||||||
.map_err(|e| Box::new(error_problem(StatusCode::BAD_REQUEST, &e)))?;
|
.map_err(refused)?;
|
||||||
Ok((token, Some(fields.homeserver), Some(resolved_user_id)))
|
Ok((token, Some(fields.homeserver), Some(resolved_user_id)))
|
||||||
}
|
}
|
||||||
other => Err(Box::new(error_problem(
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
@ -436,7 +323,7 @@ mod tests {
|
||||||
use super::super::linked_accounts::{Accounts, tests::FakeStore};
|
use super::super::linked_accounts::{Accounts, tests::FakeStore};
|
||||||
use super::{
|
use super::{
|
||||||
PutMatrixAccountRequest, homeserver_or_configured_default, is_reserved_account,
|
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 {
|
fn request(mode: &str) -> PutMatrixAccountRequest {
|
||||||
|
|
@ -696,46 +583,4 @@ mod tests {
|
||||||
Some("https://matrix.example.org")
|
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}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,5 +9,10 @@ anyhow.workspace = true
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
serde_json.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]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
|
||||||
|
|
@ -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
|
pin the device id `hyperhive-<localpart>`, so a second login **replaces** that
|
||||||
device's token instead of adding a device.
|
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
|
## 🩸 A secret is a path, never a value
|
||||||
|
|
||||||
Every error here is built from the response's status and its `errcode`, never
|
Every error here is built from the response's status and its `errcode`, never
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
//! The appservice calls against the swarm's homeserver: register, log in, ask
|
//! 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
|
//! 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,
|
//! 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.
|
//! 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
|
//! 🩸 **Every error in this module is built from the response's `status` and
|
||||||
|
|
@ -16,6 +17,11 @@ use anyhow::{Context, Result, bail};
|
||||||
/// one.
|
/// one.
|
||||||
const TIMEOUT_SECS: u64 = 10;
|
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.
|
/// Bytes of the throwaway password `/register` is given.
|
||||||
///
|
///
|
||||||
/// Protocol overhead, and stored nowhere: this account authenticates by access
|
/// Protocol overhead, and stored nowhere: this account authenticates by access
|
||||||
|
|
@ -144,6 +150,87 @@ pub async fn appservice_login(
|
||||||
access_token(&json)
|
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.
|
/// What the homeserver says a token is.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum Whoami {
|
pub enum Whoami {
|
||||||
|
|
@ -390,13 +477,14 @@ async fn post(
|
||||||
as_token: &str,
|
as_token: &str,
|
||||||
body: &serde_json::Value,
|
body: &serde_json::Value,
|
||||||
) -> Result<(reqwest::StatusCode, serde_json::Value)> {
|
) -> Result<(reqwest::StatusCode, serde_json::Value)> {
|
||||||
let resp = client
|
exchange(client.post(url).bearer_auth(as_token).json(body)).await
|
||||||
.post(url)
|
}
|
||||||
.bearer_auth(as_token)
|
|
||||||
.json(body)
|
/// Send `request`, returning the status beside the decoded body.
|
||||||
.send()
|
async fn exchange(
|
||||||
.await
|
request: reqwest::RequestBuilder,
|
||||||
.context("sending the request")?;
|
) -> Result<(reqwest::StatusCode, serde_json::Value)> {
|
||||||
|
let resp = request.send().await.context("sending the request")?;
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
let json = resp
|
let json = resp
|
||||||
.json::<serde_json::Value>()
|
.json::<serde_json::Value>()
|
||||||
|
|
@ -462,6 +550,22 @@ pub fn random_hex(bytes: usize) -> Result<String> {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn both_calls_pin_the_same_device_so_a_relogin_replaces_it() {
|
fn both_calls_pin_the_same_device_so_a_relogin_replaces_it() {
|
||||||
// The whole reason the recovery arm is safe to take repeatedly: an
|
// 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_eq!(pw.len(), PASSWORD_BYTES * 2);
|
||||||
assert!(pw.bytes().all(|b| b.is_ascii_hexdigit()), "not hex");
|
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:#}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue