Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-matrix-client/src/lib.rs
atlas d7e48091ca 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.
2026-10-17 23:26:33 +02:00

885 lines
32 KiB
Rust

//! 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). 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
//! its `errcode`, never its body.** A successful `/register` or `/login` body
//! *is* an access token, and an error body is one malformed response away from
//! being the same bytes — so a `body: {json}` in a message here would put the
//! sender token in the journal.
use anyhow::{Context, Result, bail};
/// Every call here is one client-server API round trip; a slow one is a broken
/// 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
/// token, and the recovery path when that token is lost is the appservice login
/// below rather than anything a password could open.
const PASSWORD_BYTES: usize = 32;
/// What the homeserver said about a registration attempt.
///
/// An enum rather than a string match on the error text: `M_USER_IN_USE` is an
/// *expected* answer — a registration's own `sender_localpart` is created by
/// the homeserver at startup, and an agent minted once before already exists —
/// and an expected answer should not have to be recovered from a formatted
/// message.
pub enum Registered {
/// A fresh account, and the access token minted with it.
Token(String),
/// The account is already there; it has to be logged into instead.
AlreadyExists,
}
/// An HTTP client with this module's timeout.
///
/// # Errors
/// When the TLS backend will not initialise, which is the only way building a
/// client fails.
pub fn client() -> Result<reqwest::Client> {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(TIMEOUT_SECS))
.build()
.context("building the HTTP client for the homeserver")
}
/// Create `localpart`'s account as the appservice and return its access token.
///
/// One round trip: an appservice-typed registration needs no UIAA stage, so
/// there is no session to carry and no shared registration secret in the
/// picture. The `device_id` is pinned so that a later [`appservice_login`]
/// replaces this device rather than accumulating one per run.
///
/// # Errors
/// When the request cannot be sent, the response will not decode, or the
/// homeserver refuses with anything other than `M_USER_IN_USE` — which is
/// [`Registered::AlreadyExists`] rather than an error.
pub async fn register(
client: &reqwest::Client,
base: &str,
localpart: &str,
as_token: &str,
) -> Result<Registered> {
let body = serde_json::json!({
// What makes this an appservice registration rather than an ordinary
// one: without it the homeserver asks for a UIAA flow even though the
// request carries the as_token.
"type": "m.login.application_service",
"username": localpart,
"password": random_hex(PASSWORD_BYTES)?,
"device_id": device_id(localpart),
"initial_device_display_name": format!("hyperhive ({localpart})"),
"inhibit_login": false,
});
let (status, json) = post(
client,
&format!("{base}/_matrix/client/v3/register?kind=user"),
as_token,
&body,
)
.await
.context("POST /register as the appservice")?;
if status.is_success() {
return Ok(Registered::Token(access_token(&json)?));
}
if errcode(&json) == Some("M_USER_IN_USE") {
return Ok(Registered::AlreadyExists);
}
bail!(
"the homeserver refused to register @{localpart}: {}",
why(status, &json)
);
}
/// Log in as an **existing** account using the appservice's authority, and
/// return a fresh access token for it.
///
/// No password: the appservice is authorised for every localpart in its
/// namespace, so it mints a session for one without knowing anything about the
/// account — which is just as well, since an account the homeserver created for
/// its own registration has none.
///
/// # Errors
/// When the request cannot be sent, the response will not decode, or the
/// homeserver refuses.
pub async fn appservice_login(
client: &reqwest::Client,
base: &str,
localpart: &str,
as_token: &str,
) -> Result<String> {
let body = serde_json::json!({
"type": "m.login.application_service",
"identifier": {
"type": "m.id.user",
"user": localpart,
},
// Matching `register`'s, so a re-login REPLACES that device's token
// rather than leaving a second live device behind.
"device_id": device_id(localpart),
"initial_device_display_name": format!("hyperhive ({localpart})"),
});
let (status, json) = post(
client,
&format!("{base}/_matrix/client/v3/login"),
as_token,
&body,
)
.await
.context("POST /login as the appservice")?;
if !status.is_success() {
bail!(
"the homeserver refused to log in @{localpart}: {}",
why(status, &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.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Whoami {
/// A live token, and the full user id it authenticates as.
User(String),
/// `M_UNKNOWN_TOKEN`: the homeserver does not know this token, because a
/// later login on the same device replaced it or it was revoked.
UnknownToken,
}
/// Ask the homeserver who `token` is.
///
/// # Errors
/// When the request cannot be sent, the response will not decode, or the
/// homeserver refuses with anything other than `M_UNKNOWN_TOKEN`. That one is
/// [`Whoami::UnknownToken`], because it is a verdict about the token and the
/// rest are not.
pub async fn whoami(client: &reqwest::Client, base: &str, token: &str) -> Result<Whoami> {
let resp = client
.get(format!("{base}/_matrix/client/v3/account/whoami"))
.bearer_auth(token)
.send()
.await
.context("GET /account/whoami: sending the request")?;
let status = resp.status();
let json = resp
.json::<serde_json::Value>()
.await
.context("GET /account/whoami: decoding the response as JSON")?;
whoami_verdict(status, &json)
}
/// [`whoami`]'s reading of a response, apart from the IO.
fn whoami_verdict(status: reqwest::StatusCode, json: &serde_json::Value) -> Result<Whoami> {
if status.is_success() {
return json["user_id"]
.as_str()
.map(|u| Whoami::User(u.to_owned()))
.context("the homeserver's whoami response carried no `user_id`");
}
if errcode(json) == Some("M_UNKNOWN_TOKEN") {
return Ok(Whoami::UnknownToken);
}
bail!("the homeserver refused whoami: {}", why(status, json))
}
/// The room id `alias` names.
///
/// # Errors
/// When the request cannot be sent, the response will not decode, or the
/// homeserver refuses.
pub async fn resolve_alias(
client: &reqwest::Client,
base: &str,
token: &str,
alias: &str,
) -> Result<String> {
let (status, json) = get(
client,
&format!(
"{base}/_matrix/client/v3/directory/room/{}",
path_segment(alias)
),
token,
)
.await
.with_context(|| format!("GET /directory/room for {alias}"))?;
if !status.is_success() {
bail!(
"the homeserver refused to resolve {alias}: {}",
why(status, &json)
);
}
json["room_id"]
.as_str()
.map(str::to_owned)
.with_context(|| format!("the homeserver's answer for {alias} carried no `room_id`"))
}
/// The user ids joined to `room_id`. The token's own user must be one of them.
///
/// # Errors
/// When the request cannot be sent, the response will not decode, or the
/// homeserver refuses.
pub async fn joined_members(
client: &reqwest::Client,
base: &str,
token: &str,
room_id: &str,
) -> Result<std::collections::BTreeSet<String>> {
let (status, json) = get(
client,
&format!(
"{base}/_matrix/client/v3/rooms/{}/joined_members",
path_segment(room_id)
),
token,
)
.await
.with_context(|| format!("GET /rooms/{room_id}/joined_members"))?;
joined_verdict(status, &json).with_context(|| format!("the members of {room_id}"))
}
/// [`joined_members`]'s reading of a response, apart from the IO.
fn joined_verdict(
status: reqwest::StatusCode,
json: &serde_json::Value,
) -> Result<std::collections::BTreeSet<String>> {
if !status.is_success() {
bail!("the homeserver refused to list them: {}", why(status, json));
}
json["joined"]
.as_object()
.map(|joined| joined.keys().cloned().collect())
.context("the homeserver's member list carried no `joined`")
}
/// Whether `user_id` has an account on this homeserver.
///
/// Asked through the profile endpoint, which answers `M_NOT_FOUND` for a
/// local user id with no account.
///
/// # Errors
/// When the request cannot be sent, the response will not decode, or the
/// homeserver refuses with anything other than `M_NOT_FOUND`.
pub async fn user_exists(
client: &reqwest::Client,
base: &str,
token: &str,
user_id: &str,
) -> Result<bool> {
let (status, json) = get(
client,
&format!("{base}/_matrix/client/v3/profile/{}", path_segment(user_id)),
token,
)
.await
.with_context(|| format!("GET /profile/{user_id}"))?;
profile_verdict(status, &json).with_context(|| format!("{user_id}'s profile"))
}
/// [`user_exists`]'s reading of a response, apart from the IO.
///
/// Only `M_NOT_FOUND` means "no account". Any other refusal says nothing
/// about the account, so it is an error rather than a `false` a caller would
/// act on.
fn profile_verdict(status: reqwest::StatusCode, json: &serde_json::Value) -> Result<bool> {
if status.is_success() {
return Ok(true);
}
if errcode(json) == Some("M_NOT_FOUND") {
return Ok(false);
}
bail!("the homeserver refused: {}", why(status, json))
}
/// Send `body` to `room_id` as an `m.text` message, and return its event id.
///
/// # Errors
/// When the request cannot be sent, the response will not decode, or the
/// homeserver refuses.
pub async fn send_text(
client: &reqwest::Client,
base: &str,
token: &str,
room_id: &str,
body: &str,
) -> Result<String> {
let resp = client
.put(format!(
"{base}/_matrix/client/v3/rooms/{}/send/m.room.message/{}",
path_segment(room_id),
random_hex(8)?
))
.bearer_auth(token)
.json(&serde_json::json!({ "msgtype": "m.text", "body": body }))
.send()
.await
.with_context(|| format!("PUT a message into {room_id}: sending the request"))?;
let status = resp.status();
let json = resp
.json::<serde_json::Value>()
.await
.with_context(|| format!("PUT a message into {room_id}: decoding the response"))?;
if !status.is_success() {
bail!(
"the homeserver refused a message into {room_id}: {}",
why(status, &json)
);
}
json["event_id"]
.as_str()
.map(str::to_owned)
.with_context(|| {
format!("the homeserver's answer to a message into {room_id} carried no `event_id`")
})
}
/// `value` percent-encoded for one URL path segment: room ids, aliases and
/// user ids carry `!`, `#`, `@` and `:`, and `#` would otherwise end the path.
fn path_segment(value: &str) -> String {
use std::fmt::Write as _;
value.bytes().fold(String::new(), |mut acc, b| {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
acc.push(char::from(b));
} else {
// Infallible, for `random_hex`'s reason.
let _ = write!(acc, "%{b:02X}");
}
acc
})
}
/// One authenticated GET, returning the status beside the decoded body.
async fn get(
client: &reqwest::Client,
url: &str,
token: &str,
) -> Result<(reqwest::StatusCode, serde_json::Value)> {
let resp = client
.get(url)
.bearer_auth(token)
.send()
.await
.context("sending the request")?;
let status = resp.status();
let json = resp
.json::<serde_json::Value>()
.await
.context("decoding the response as JSON")?;
Ok((status, json))
}
/// The device every token this binary mints is pinned to.
fn device_id(localpart: &str) -> String {
format!("hyperhive-{localpart}")
}
/// One authenticated JSON POST, returning the status beside the decoded body.
async fn post(
client: &reqwest::Client,
url: &str,
as_token: &str,
body: &serde_json::Value,
) -> Result<(reqwest::StatusCode, serde_json::Value)> {
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>()
.await
.context("decoding the response as JSON")?;
Ok((status, json))
}
/// Pull `access_token` out of a successful response.
fn access_token(json: &serde_json::Value) -> Result<String> {
json["access_token"]
.as_str()
.map(str::to_owned)
// Not `{json}`: on the success path this object holds the credential,
// and a response missing the field is exactly when a reflex to print it
// would fire.
.context("the homeserver's response carried no `access_token`")
}
/// The matrix error code, when the body is a standard error object.
fn errcode(json: &serde_json::Value) -> Option<&str> {
json["errcode"].as_str()
}
/// Everything about a refusal that is safe to put in a message.
///
/// The `errcode` is a closed vocabulary from the spec and the status is a
/// number; between them they say which of the ladder's arms was taken. The
/// `error` string beside them is free-form homeserver text, so it stays out.
fn why(status: reqwest::StatusCode, json: &serde_json::Value) -> String {
match errcode(json) {
Some(code) => format!("HTTP {status}, errcode {code}"),
None => format!("HTTP {status}, no errcode"),
}
}
/// `bytes` random bytes from `/dev/urandom`, as hex: [`register`]'s
/// throwaway password, and the appservice tokens `swarm-matrix-ctl` mints.
///
/// From `/dev/urandom` directly rather than through an RNG crate: the kernel is
/// already the source any such crate would reach for here.
///
/// # Errors
/// When `/dev/urandom` cannot be read.
pub fn random_hex(bytes: usize) -> Result<String> {
use std::io::Read as _;
let mut buf = vec![0u8; bytes];
std::fs::File::open("/dev/urandom")
.context("opening /dev/urandom")?
.read_exact(&mut buf)
.context("reading from /dev/urandom")?;
Ok(buf.iter().fold(String::new(), |mut acc, b| {
use std::fmt::Write as _;
// Infallible: `write!` into a `String` only fails if the formatter
// does, and `{:02x}` of a `u8` has nothing to fail at.
let _ = write!(acc, "{b:02x}");
acc
}))
}
#[cfg(test)]
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
// unpinned login mints a NEW device each time, and a homeserver
// accumulating devices for this account is one where revoking the credential
// means finding all of them.
assert_eq!(device_id("hive"), "hyperhive-hive");
}
#[test]
fn a_refusal_is_described_by_its_errcode_and_never_by_its_body() {
// 🩸 The invariant this module exists to keep. `error` is free-form
// homeserver text and `access_token` is the credential itself; a
// message built from the body would carry whichever of them the
// response happened to hold.
let json = serde_json::json!({
"errcode": "M_FORBIDDEN",
"error": "some free-form text",
"access_token": "syt_the_actual_secret",
});
let message = why(reqwest::StatusCode::FORBIDDEN, &json);
assert!(message.contains("M_FORBIDDEN"), "{message}");
assert!(!message.contains("syt_the_actual_secret"), "{message}");
assert!(!message.contains("free-form"), "{message}");
}
#[test]
fn a_refusal_with_no_errcode_still_produces_a_message() {
// A homeserver behind a proxy answers with HTML, not a matrix error
// object. The status is then the only thing there is to say, and
// saying it is better than an empty report.
let message = why(reqwest::StatusCode::BAD_GATEWAY, &serde_json::json!({}));
assert!(message.contains("502"), "{message}");
}
#[test]
fn the_expected_already_exists_answer_is_recognised_by_its_errcode() {
// Matched on the spec's code rather than on message text, because this
// is the arm a healthy homeserver takes every time: an account minted
// on an earlier pass already exists.
let json = serde_json::json!({ "errcode": "M_USER_IN_USE", "error": "User ID taken" });
assert_eq!(errcode(&json), Some("M_USER_IN_USE"));
}
#[test]
fn a_response_without_a_token_is_an_error_that_does_not_quote_it() {
let e = access_token(&serde_json::json!({ "user_id": "@hive:t.local" }))
.expect_err("no access_token in this object");
assert!(!format!("{e}").contains("@hive:t.local"), "{e}");
}
#[test]
fn whoami_reads_the_user_of_a_live_token() {
let json =
serde_json::json!({ "user_id": "@atlas:t.local", "device_id": "hyperhive-atlas" });
assert_eq!(
whoami_verdict(reqwest::StatusCode::OK, &json).expect("a live token"),
Whoami::User("@atlas:t.local".to_owned())
);
}
#[test]
fn a_replaced_token_is_a_verdict_and_not_an_error() {
// The arm a mint decides on: this is how a token some other login on
// the same device replaced looks.
let json = serde_json::json!({ "errcode": "M_UNKNOWN_TOKEN", "error": "Unknown token" });
assert_eq!(
whoami_verdict(reqwest::StatusCode::UNAUTHORIZED, &json).expect("a verdict"),
Whoami::UnknownToken
);
}
#[test]
fn any_other_refusal_is_an_error_so_nobody_mints_on_it() {
// The control for the arm above: a 401 with another code, or a 5xx,
// says nothing about the token, and a caller that minted on it would
// rotate every token during an outage.
for (status, json) in [
(
reqwest::StatusCode::UNAUTHORIZED,
serde_json::json!({ "errcode": "M_MISSING_TOKEN" }),
),
(reqwest::StatusCode::BAD_GATEWAY, serde_json::json!({})),
] {
assert!(whoami_verdict(status, &json).is_err(), "{status}");
}
}
#[test]
fn a_profile_answer_means_the_account_exists() {
let json = serde_json::json!({ "displayname": "mara" });
assert!(profile_verdict(reqwest::StatusCode::OK, &json).expect("a verdict"));
}
#[test]
fn not_found_is_no_account_and_not_an_error() {
let json =
serde_json::json!({ "errcode": "M_NOT_FOUND", "error": "Profile was not found." });
assert!(!profile_verdict(reqwest::StatusCode::NOT_FOUND, &json).expect("a verdict"));
}
#[test]
fn any_other_profile_refusal_is_an_error_and_not_no_account() {
// The control for the arm above: a 403 says nothing about whether the
// account exists, and reading it as "no account" would skip a grant.
for (status, json) in [
(
reqwest::StatusCode::FORBIDDEN,
serde_json::json!({ "errcode": "M_FORBIDDEN" }),
),
(reqwest::StatusCode::NOT_FOUND, serde_json::json!({})),
(reqwest::StatusCode::BAD_GATEWAY, serde_json::json!({})),
] {
assert!(profile_verdict(status, &json).is_err(), "{status} {json}");
}
}
#[test]
fn the_joined_members_are_the_keys_of_joined() {
let json = serde_json::json!({ "joined": {
"@swarm:t.local": { "display_name": "swarm" },
"@mara:t.local": {},
}});
let members = joined_verdict(reqwest::StatusCode::OK, &json).expect("a member list");
assert_eq!(
members.into_iter().collect::<Vec<_>>(),
["@mara:t.local", "@swarm:t.local"]
);
}
#[test]
fn a_member_list_without_joined_or_a_refusal_is_an_error() {
// Never an empty set: that would read as "nobody is admin yet" and
// send a grant for every operator.
assert!(joined_verdict(reqwest::StatusCode::OK, &serde_json::json!({})).is_err());
assert!(
joined_verdict(
reqwest::StatusCode::FORBIDDEN,
&serde_json::json!({ "errcode": "M_FORBIDDEN" })
)
.is_err()
);
}
#[test]
fn a_path_segment_escapes_what_would_end_or_split_the_path() {
assert_eq!(path_segment("#admins:t.local"), "%23admins%3At.local");
assert_eq!(path_segment("!abc:t.local"), "%21abc%3At.local");
assert_eq!(path_segment("@a.b_c-d:t.local"), "%40a.b_c-d%3At.local");
assert_eq!(path_segment("a/b"), "a%2Fb");
}
#[test]
fn a_minted_password_is_hex_of_the_declared_length() {
// The control on the hex fold: a short or non-hex password would be
// accepted by the homeserver and only surface much later, if at all.
let pw = random_hex(PASSWORD_BYTES).expect("/dev/urandom is readable");
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:#}");
}
}
}