From 52446cb273bcafcd13976afbe572f6566a625f6e Mon Sep 17 00:00:00 2001
From: iris
Date: Wed, 9 Sep 2026 00:51:13 +0200
Subject: [PATCH 1/2] matrix accounts: server-side password login for
swarm-controller
Extends PutMatrixAccountRequest with a mode field (token, the existing
behavior and default; or password). Password mode has swarm-controller
itself perform m.login.password against the caller-given homeserver
(mirrors hive-c0re's own /api/matrix-account-login for the hive-local
case) and stores the resulting token instead of a caller-supplied one
-- the password is used once, over this PUT, and never stored. Also
adds the 'main is reserved' guard hive-c0re's login form already has,
which swarm-controller had no equivalent of before this.
swarm-ui's link-matrix-account form gets a credential-mode toggle
wired to the same contract: token mode is unchanged, password mode
swaps the token field for user-id + password fields and makes
homeserver required (no hive-side fallback to resolve it against, per
PutMatrixAccountRequest::homeserver's own doc).
Per #4122.
---
.../src/pages/LinkMatrixAccountForm.tsx | 144 +++++++++++----
swarm-controller/src/matrix_account.rs | 164 +++++++++++++++++-
2 files changed, 263 insertions(+), 45 deletions(-)
diff --git a/frontend/packages/swarm-ui/src/pages/LinkMatrixAccountForm.tsx b/frontend/packages/swarm-ui/src/pages/LinkMatrixAccountForm.tsx
index e27f5750..081c0b81 100644
--- a/frontend/packages/swarm-ui/src/pages/LinkMatrixAccountForm.tsx
+++ b/frontend/packages/swarm-ui/src/pages/LinkMatrixAccountForm.tsx
@@ -1,11 +1,17 @@
-// — writes a matrix account credential (token +
-// optional homeserver) for one agent into the swarm secret store.
-// PUTs `/api/hives/{hive}/agents/{agent}/matrix-accounts/{account}` per
-// the contract atlas posted on the OpenBao swarm-secret-store adoption
-// issue: `{ token, homeserver? }` body, 200 on success, 400/503/500 on
-// the documented failure arms — all handled generically via
-// `readApiError`/`ApiErrorPanel`, same as every other form here, since
-// the response is `problem+json` regardless of which arm fired.
+// — writes a matrix account credential for one
+// agent into the swarm secret store, in one of two modes (mirrors
+// swarm-controller's `PutMatrixAccountRequest`):
+// token — paste an already-obtained bearer token, optional homeserver.
+// password — user id + password; swarm-controller logs into the
+// (required, here) homeserver itself and stores the token
+// that comes back. The password is sent once, over this
+// PUT, straight to swarm-controller — never held here past
+// the request, never sent to the hive.
+// PUTs `/api/hives/{hive}/agents/{agent}/matrix-accounts/{account}` —
+// 200 (`{ user_id? }`) on success, 400/503/500 on the documented failure
+// arms — all handled generically via `readApiError`/`ApiErrorPanel`,
+// same as every other form here, since the response is `problem+json`
+// regardless of which arm fired.
//
// No "linked accounts" list to show first: no route exposes one (a
// credential store shouldn't hand a secret back out anyway), so this is
@@ -15,12 +21,6 @@
// mara's rulings on that issue — there's no assignment step here yet,
// just the write.
//
-// Deliberately built against the contract before the endpoint exists
-// (atlas: "it does not change when the implementation lands") — until
-// item 2 merges this 404s, same as any other page pointed at a route
-// that isn't live yet. Nothing here depends on the backend being up to
-// be correct.
-//
// Rendered inside a `Dialog` from `AgentsPage`, one per row — same
// mount shape as `CreateAgentForm`, own file for the same reason (a
// multi-field form with real submit-state handling is more than
@@ -31,9 +31,12 @@ import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Panel } from "../ui/panel/Panel.js";
import { TextField } from "../ui/text-field/TextField.js";
+import { SelectField } from "../ui/select-field/SelectField.js";
import { Button } from "../ui/button/Button.js";
import "./LinkMatrixAccountForm.css";
+type Mode = "token" | "password";
+
// The store's own key-space, not `hive_types::Ident` — atlas's measured
// gap on the issue: an agent name is an `Ident` (lowercase-only) but an
// account name is a bare attrset key under `matrixAccounts`, so it
@@ -45,9 +48,18 @@ const ACCOUNT_PATTERN = "[A-Za-z0-9_\\-]{1,63}";
type SubmitState =
| { status: "idle" }
| { status: "submitting" }
- | { status: "done" }
+ // `userId` is set in password mode (the server resolves it from the
+ // login response) and `undefined` in token mode — see
+ // `PutMatrixAccountResponse`'s own doc for why token mode doesn't
+ // spend a round trip resolving one.
+ | { status: "done"; userId?: string }
| { status: "error"; problem: ProblemDetails };
+const MODE_OPTIONS = [
+ { value: "token", label: "paste a token" },
+ { value: "password", label: "log in with a password" },
+];
+
export function LinkMatrixAccountForm({
hive,
agent,
@@ -56,7 +68,10 @@ export function LinkMatrixAccountForm({
agent: string;
}) {
const [account, setAccount] = useState("");
+ const [mode, setMode] = useState("token");
const [token, setToken] = useState("");
+ const [userId, setUserId] = useState("");
+ const [password, setPassword] = useState("");
const [homeserver, setHomeserver] = useState("");
const [result, setResult] = useState({ status: "idle" });
@@ -69,26 +84,43 @@ export function LinkMatrixAccountForm({
{
method: "PUT",
headers: { "content-type": "application/json" },
- body: JSON.stringify({
- token,
- // Omitted rather than sent empty — the contract marks it
- // optional, and an empty string isn't "no homeserver", it's
- // a homeserver named "".
- homeserver: homeserver.trim() || undefined,
- }),
+ body: JSON.stringify(
+ mode === "token"
+ ? {
+ mode,
+ token,
+ // Omitted rather than sent empty — the contract marks
+ // it optional in token mode, and an empty string
+ // isn't "no homeserver", it's a homeserver named "".
+ homeserver: homeserver.trim() || undefined,
+ }
+ : {
+ mode,
+ user_id: userId,
+ password,
+ // Required in password mode — the field below enforces
+ // that before this branch is ever reachable.
+ homeserver: homeserver.trim(),
+ },
+ ),
},
);
if (!r.ok) {
setResult({ status: "error", problem: await readApiError(r) });
return;
}
- setResult({ status: "done" });
- // Token cleared on success — nothing left needing it in the form
- // once the store holds it, and a secret sitting in a field after
- // the action that used it is exactly the kind of stale-credential
- // risk the whole store exists to avoid. Account name kept, so the
- // success message below reads against what actually landed.
+ const body = (await r.json().catch(() => ({}))) as {
+ user_id?: string;
+ };
+ setResult({ status: "done", userId: body.user_id });
+ // Secrets cleared on success — nothing left needing them in the
+ // form once the store holds the derived credential, and a secret
+ // sitting in a field after the action that used it is exactly the
+ // kind of stale-credential risk the whole store exists to avoid.
+ // Account name kept, so the success message below reads against
+ // what actually landed.
setToken("");
+ setPassword("");
} catch (err) {
setResult({ status: "error", problem: { detail: String(err) } });
}
@@ -118,20 +150,54 @@ export function LinkMatrixAccountForm({
required
onInput={setAccount}
/>
- setMode(v as Mode)}
/>
+ {mode === "token" ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
)}
{result.status === "error" && (
diff --git a/swarm-controller/src/matrix_account.rs b/swarm-controller/src/matrix_account.rs
index 626cb7f5..2e24303f 100644
--- a/swarm-controller/src/matrix_account.rs
+++ b/swarm-controller/src/matrix_account.rs
@@ -10,11 +10,21 @@
//! ⚠️ Store first, notify second, and the order cannot be swapped: a notice
//! that overtakes its own write reaches a hive that reads nothing, and the
//! hive deliberately does not retry.
+//!
+//! Two credential modes, chosen by `PutMatrixAccountRequest::mode`: `token`
+//! (default, back-compat with the original blind-store shape — the caller
+//! already has a bearer token) and `password` (this daemon performs
+//! `m.login.password` against the caller-given homeserver itself and stores
+//! the resulting token; the password is never stored, and is not sent to the
+//! hive either — only the derived token is). Mirrors what hive-c0re's own
+//! `/api/matrix-account-login` does for a *hive-local* account, done here
+//! instead so the browser never has to hold the password long enough to call
+//! an arbitrary homeserver directly.
use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
-use serde::Deserialize;
+use serde::{Deserialize, Serialize};
use swarm_queue_client::{CredentialNotice, credential_subject};
use swarm_secret_client::{SecretStore, matrix};
use utoipa::ToSchema;
@@ -30,20 +40,54 @@ use super::{AppState, error_problem, swarm_hive};
/// (`allowed_common_names`), so the two are deliberately different strings.
const CERT_ROLE: &str = "swarm-controller";
+fn default_mode() -> String {
+ "token".to_owned()
+}
+
/// The credential to store for one agent's external matrix account.
#[derive(Debug, Deserialize, ToSchema)]
pub struct PutMatrixAccountRequest {
- /// The access token. Never logged, and never returned by this route.
- token: String,
+ /// `"token"` (default, back-compat) or `"password"`. Token mode stores
+ /// `token` as given; password mode logs into `homeserver` with `user_id`
+ /// + `password` and stores the token that comes back instead.
+ #[serde(default = "default_mode")]
+ #[schema(example = "token")]
+ mode: String,
+ /// The access token. Required (and used as given) in token mode; ignored
+ /// in password mode, where the token comes from the login instead. Never
+ /// logged, and never returned by this route.
+ token: Option,
+ /// Password-mode only: the matrix user id (or bare localpart) to log in
+ /// as.
+ user_id: Option,
+ /// Password-mode only. Never logged and never stored — only the token
+ /// `m.login.password` returns is.
+ password: Option,
/// The account's homeserver, when it is not this swarm's own.
///
/// Stored beside the token rather than sent on the notice: a notice is a
/// queue message, so a homeserver carried there would exist only in
/// flight, with nowhere to reconstruct it from on a re-delivery.
+ ///
+ /// Optional in token mode (omitted means "resolve to the agent's own
+ /// `hyperhive.matrix.url` on the hive side" — this route never needs to
+ /// know it itself for a blind store). **Required** in password mode:
+ /// logging in needs somewhere to log in against, and unlike token mode
+ /// there is no hive-side fallback to defer to.
#[schema(example = "https://matrix.example.org")]
homeserver: Option,
}
+/// `put_matrix_account`'s success body.
+#[derive(Debug, Serialize, ToSchema)]
+pub struct PutMatrixAccountResponse {
+ /// The account's matrix user id, recovered from the login response in
+ /// password mode. `None` in token mode — the caller already knows which
+ /// account their own token belongs to, and this route does not spend a
+ /// `whoami` round trip validating a token it was simply handed.
+ user_id: Option,
+}
+
/// Store an agent's external matrix account credential and notify its hive.
///
/// Idempotent: the store keeps versions, so repeating a call replaces the
@@ -58,8 +102,8 @@ pub struct PutMatrixAccountRequest {
),
request_body = PutMatrixAccountRequest,
responses(
- (status = 200, description = "stored, and the hive has been told"),
- (status = 400, description = "a name is not an identifier, the account name is not a single path segment, or the hive is not in this swarm (problem+json)", body = String),
+ (status = 200, description = "stored, and the hive has been told", body = PutMatrixAccountResponse),
+ (status = 400, description = "a name is not an identifier, the account name is not a single path segment, the account is 'main' (reserved), the mode is unrecognized, a mode's required fields are missing, or the hive is not in this swarm (problem+json)", body = String),
(status = 503, description = "no swarm queue is wired up (problem+json)", body = String),
(status = 500, description = "the store write, the encode or the publish failed (problem+json)", body = String),
),
@@ -69,7 +113,7 @@ pub async fn put_matrix_account(
State(state): State,
axum::extract::Path((hive, agent, account)): axum::extract::Path<(String, String, String)>,
Json(req): Json,
-) -> Result {
+) -> Result, problem_details::ProblemDetails> {
// The queue first, so a deployment that has none answers 503 whatever the
// caller spelled — and before the store is touched, so a request that
// could never be delivered does not leave a credential behind.
@@ -88,6 +132,59 @@ pub async fn put_matrix_account(
// charset is its rule to state, not this handler's to restate.
let secret_path = matrix::account_path(&agent, &account)
.map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.to_string()))?;
+ // `main` is the hive-internal account `nix/agent-modules/matrix.nix`
+ // synthesizes per agent from `hyperhive.matrix.url` — the schema there
+ // forbids declaring a key by that name for the same reason this route
+ // refuses to write one: an extra account literally named `main` would
+ // not overwrite the real one (it lands at a different token-file suffix)
+ // but would confuse anything that lists accounts by name. Same guard
+ // hive-c0re's own `/api/matrix-account-login` applies, checked before any
+ // mode-specific work (including a network login) runs.
+ if account == "main" {
+ return Err(error_problem(
+ StatusCode::BAD_REQUEST,
+ "'main' is the hive-internal account, synthesized per agent from \
+ hyperhive.matrix.url — it cannot be set through this route.",
+ ));
+ }
+
+ // Resolve the mode to a concrete (token, homeserver, resolved user id) —
+ // password mode's network call happens here, before the store is
+ // touched, so a failed login leaves no partial state behind.
+ let (token, homeserver, user_id) = match req.mode.as_str() {
+ "token" => {
+ let Some(token) = req.token else {
+ return Err(error_problem(
+ StatusCode::BAD_REQUEST,
+ "token mode needs a token",
+ ));
+ };
+ (token, req.homeserver, None)
+ }
+ "password" => {
+ let (Some(homeserver), Some(user_id), Some(password)) = (
+ req.homeserver.as_deref(),
+ req.user_id.as_deref(),
+ req.password.as_deref(),
+ ) else {
+ return Err(error_problem(
+ StatusCode::BAD_REQUEST,
+ "password mode needs homeserver, user_id, and password",
+ ));
+ };
+ let homeserver = homeserver.trim_end_matches('/').to_owned();
+ let (token, resolved_user_id) = matrix_password_login(&homeserver, user_id, password)
+ .await
+ .map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e))?;
+ (token, Some(homeserver), Some(resolved_user_id))
+ }
+ other => {
+ return Err(error_problem(
+ StatusCode::BAD_REQUEST,
+ &format!("unknown mode {other:?} (want token|password)"),
+ ));
+ }
+ };
let store = SecretStore::from_env(CERT_ROLE).await.map_err(|e| {
tracing::warn!(error = %e, "connecting to the swarm secret store failed");
@@ -97,8 +194,8 @@ pub async fn put_matrix_account(
.write(
&secret_path,
&matrix::Credential {
- value: req.token,
- homeserver: req.homeserver,
+ value: token,
+ homeserver,
},
)
.await
@@ -140,5 +237,54 @@ pub async fn put_matrix_account(
})?;
tracing::info!(%subject, %hive, %agent, %account, "credential stored; hive notified");
- Ok(StatusCode::OK)
+ Ok(Json(PutMatrixAccountResponse { user_id }))
+}
+
+/// 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 = reqwest::Client::new()
+ .post(&url)
+ .json(&body)
+ .send()
+ .await
+ .map_err(|e| format!("POST /login: {e}"))?;
+ let status = resp.status();
+ let json: serde_json::Value = resp
+ .json()
+ .await
+ .map_err(|e| format!("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()))
}
From ec67d2dd36281f9c1311e15947e0f4b0db256050 Mon Sep 17 00:00:00 2001
From: iris
Date: Wed, 9 Sep 2026 01:07:40 +0200
Subject: [PATCH 2/2] address review: extract mode resolution, add unit tests,
drop Debug
- resolve_credential (+ token_credential / password_fields helpers)
extracted out of put_matrix_account, which was tripping
clippy::too_many_lines (101/100). Both helpers surface plain &str
errors rather than ProblemDetails to dodge clippy::result_large_err on
a private fn (put_matrix_account itself is exempt only via clippy's
avoid-breaking-exported-api default, which does not cover these);
resolve_credential boxes its own ProblemDetails Err for the same
reason, unboxed at its one call site.
- Added unit tests for the extracted logic: main-reserved, token-mode
missing-token / pass-through, password-mode missing-fields / trailing-
slash trim, and two async resolve_credential end-to-end checks
(token mode, unknown mode) that need no network access.
- Dropped PutMatrixAccountRequest's Debug derive to match
hive-c0re::dashboard::matrix_accounts::MatrixLoginForm's existing
precedent of not deriving Debug on a struct carrying a password field.
---
swarm-controller/src/matrix_account.rs | 230 +++++++++++++++++++++----
1 file changed, 192 insertions(+), 38 deletions(-)
diff --git a/swarm-controller/src/matrix_account.rs b/swarm-controller/src/matrix_account.rs
index 2e24303f..31843bda 100644
--- a/swarm-controller/src/matrix_account.rs
+++ b/swarm-controller/src/matrix_account.rs
@@ -45,7 +45,11 @@ fn default_mode() -> String {
}
/// The credential to store for one agent's external matrix account.
-#[derive(Debug, Deserialize, ToSchema)]
+///
+/// No `Debug` derive — matching `hive-c0re::dashboard::matrix_accounts`'s
+/// analogous `MatrixLoginForm`, which also carries a password field and
+/// deliberately omits it so nothing can `{:?}`-log this by accident.
+#[derive(Deserialize, ToSchema)]
pub struct PutMatrixAccountRequest {
/// `"token"` (default, back-compat) or `"password"`. Token mode stores
/// `token` as given; password mode logs into `homeserver` with `user_id`
@@ -140,7 +144,7 @@ pub async fn put_matrix_account(
// but would confuse anything that lists accounts by name. Same guard
// hive-c0re's own `/api/matrix-account-login` applies, checked before any
// mode-specific work (including a network login) runs.
- if account == "main" {
+ if is_reserved_account(&account) {
return Err(error_problem(
StatusCode::BAD_REQUEST,
"'main' is the hive-internal account, synthesized per agent from \
@@ -148,43 +152,9 @@ pub async fn put_matrix_account(
));
}
- // Resolve the mode to a concrete (token, homeserver, resolved user id) —
- // password mode's network call happens here, before the store is
+ // Password mode's network call happens here, before the store is
// touched, so a failed login leaves no partial state behind.
- let (token, homeserver, user_id) = match req.mode.as_str() {
- "token" => {
- let Some(token) = req.token else {
- return Err(error_problem(
- StatusCode::BAD_REQUEST,
- "token mode needs a token",
- ));
- };
- (token, req.homeserver, None)
- }
- "password" => {
- let (Some(homeserver), Some(user_id), Some(password)) = (
- req.homeserver.as_deref(),
- req.user_id.as_deref(),
- req.password.as_deref(),
- ) else {
- return Err(error_problem(
- StatusCode::BAD_REQUEST,
- "password mode needs homeserver, user_id, and password",
- ));
- };
- let homeserver = homeserver.trim_end_matches('/').to_owned();
- let (token, resolved_user_id) = matrix_password_login(&homeserver, user_id, password)
- .await
- .map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e))?;
- (token, Some(homeserver), Some(resolved_user_id))
- }
- other => {
- return Err(error_problem(
- StatusCode::BAD_REQUEST,
- &format!("unknown mode {other:?} (want token|password)"),
- ));
- }
- };
+ let (token, homeserver, user_id) = resolve_credential(&req).await.map_err(|b| *b)?;
let store = SecretStore::from_env(CERT_ROLE).await.map_err(|e| {
tracing::warn!(error = %e, "connecting to the swarm secret store failed");
@@ -240,6 +210,92 @@ pub async fn put_matrix_account(
Ok(Json(PutMatrixAccountResponse { user_id }))
}
+/// Whether `account` is the hive-internal name every hive synthesizes per
+/// agent (`nix/agent-modules/matrix.nix`) — see the call site's own comment
+/// for why this route must never write one.
+fn is_reserved_account(account: &str) -> bool {
+ account == "main"
+}
+
+/// Token-mode's only requirement: a token was actually given. Split out of
+/// `resolve_credential` (with `password_fields` below) so each mode's
+/// validation is unit-testable directly — only the actual network login in
+/// `resolve_credential` itself needs an async runtime to exercise. Returns a
+/// plain `&'static str` rather than a `ProblemDetails` — `clippy::result_large_err`
+/// (this workspace runs `pedantic = deny`) flags a private fn returning one of
+/// those directly; `put_matrix_account` itself is exempt only because it is
+/// `pub` (clippy's `avoid-breaking-exported-api` default), which these
+/// helpers are not.
+fn token_credential(
+ req: &PutMatrixAccountRequest,
+) -> Result<(String, Option), &'static str> {
+ let Some(token) = req.token.clone() else {
+ return Err("token mode needs a token");
+ };
+ Ok((token, req.homeserver.clone()))
+}
+
+/// 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 `//`.
+struct PasswordFields<'a> {
+ homeserver: String,
+ user_id: &'a str,
+ password: &'a str,
+}
+
+fn password_fields(req: &PutMatrixAccountRequest) -> Result, &'static str> {
+ let (Some(homeserver), Some(user_id), Some(password)) = (
+ req.homeserver.as_deref(),
+ req.user_id.as_deref(),
+ req.password.as_deref(),
+ ) else {
+ return Err("password mode needs homeserver, user_id, and password");
+ };
+ Ok(PasswordFields {
+ homeserver: homeserver.trim_end_matches('/').to_owned(),
+ user_id,
+ password,
+ })
+}
+
+/// Resolve a request's credential mode to a concrete `(token, homeserver,
+/// resolved user id)`. Extracted out of `put_matrix_account` to keep that
+/// function under `clippy::too_many_lines`, and so the mode branching has
+/// something to unit-test directly (via `token_credential`/`password_fields`
+/// above) instead of only through the full route.
+///
+/// `Box`ed error for the same `result_large_err` reason `token_credential`'s
+/// doc explains — this fn is private too, so it does not get `put_matrix_account`'s
+/// exported-API exemption. Unboxed at the one call site instead of changing
+/// `put_matrix_account`'s own (exempt, and part of the route's documented
+/// contract) return type.
+async fn resolve_credential(
+ req: &PutMatrixAccountRequest,
+) -> Result<(String, Option, Option), Box> {
+ match req.mode.as_str() {
+ "token" => {
+ let (token, homeserver) = token_credential(req)
+ .map_err(|e| Box::new(error_problem(StatusCode::BAD_REQUEST, e)))?;
+ Ok((token, homeserver, None))
+ }
+ "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)))?;
+ Ok((token, Some(fields.homeserver), Some(resolved_user_id)))
+ }
+ other => Err(Box::new(error_problem(
+ StatusCode::BAD_REQUEST,
+ &format!("unknown mode {other:?} (want token|password)"),
+ ))),
+ }
+}
+
/// POST `m.login.password` to `/_matrix/client/v3/login`.
/// Returns `(access_token, user_id)`.
///
@@ -288,3 +344,101 @@ async fn matrix_password_login(
.ok_or_else(|| "login response missing user_id".to_owned())?;
Ok((token.to_owned(), uid.to_owned()))
}
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ PutMatrixAccountRequest, is_reserved_account, password_fields, resolve_credential,
+ token_credential,
+ };
+
+ fn request(mode: &str) -> PutMatrixAccountRequest {
+ PutMatrixAccountRequest {
+ mode: mode.to_owned(),
+ token: None,
+ user_id: None,
+ password: None,
+ homeserver: None,
+ }
+ }
+
+ #[test]
+ fn main_is_reserved_but_nothing_else_is() {
+ assert!(is_reserved_account("main"));
+ assert!(!is_reserved_account("ops-relay"));
+ // Case-sensitive on purpose: `matrixAccounts` is a nix attrset, so
+ // `Main` is a distinct, legal key from the reserved `main`.
+ assert!(!is_reserved_account("Main"));
+ }
+
+ #[test]
+ fn token_mode_needs_a_token() {
+ assert!(token_credential(&request("token")).is_err());
+ }
+
+ #[test]
+ fn token_mode_passes_through_token_and_homeserver_unchanged() {
+ let mut req = request("token");
+ req.token = Some("t0k3n".to_owned());
+ req.homeserver = Some("https://matrix.example.org".to_owned());
+ let (token, homeserver) = token_credential(&req).expect("token + homeserver both given");
+ assert_eq!(token, "t0k3n");
+ assert_eq!(homeserver.as_deref(), Some("https://matrix.example.org"));
+ }
+
+ #[test]
+ fn password_mode_needs_all_three_fields() {
+ let mut req = request("password");
+ assert!(password_fields(&req).is_err(), "none given");
+ req.homeserver = Some("https://matrix.example.org".to_owned());
+ assert!(
+ password_fields(&req).is_err(),
+ "still missing user_id + password"
+ );
+ req.user_id = Some("@a:matrix.example.org".to_owned());
+ assert!(password_fields(&req).is_err(), "still missing password");
+ req.password = Some("hunter2".to_owned());
+ assert!(password_fields(&req).is_ok(), "now all three given");
+ }
+
+ #[test]
+ fn password_mode_trims_a_trailing_slash_off_the_homeserver() {
+ let mut req = request("password");
+ req.homeserver = Some("https://matrix.example.org/".to_owned());
+ req.user_id = Some("@a:matrix.example.org".to_owned());
+ req.password = Some("hunter2".to_owned());
+ let fields = password_fields(&req).expect("all three fields given");
+ assert_eq!(fields.homeserver, "https://matrix.example.org");
+ }
+
+ #[test]
+ fn password_mode_leaves_a_homeserver_with_no_trailing_slash_unchanged() {
+ let mut req = request("password");
+ req.homeserver = Some("https://matrix.example.org".to_owned());
+ req.user_id = Some("@a:matrix.example.org".to_owned());
+ req.password = Some("hunter2".to_owned());
+ let fields = password_fields(&req).expect("all three fields given");
+ assert_eq!(fields.homeserver, "https://matrix.example.org");
+ }
+
+ #[tokio::test]
+ async fn resolve_credential_token_mode_end_to_end() {
+ let mut req = request("token");
+ req.token = Some("t0k3n".to_owned());
+ let (token, homeserver, user_id) = resolve_credential(&req)
+ .await
+ .expect("token mode with a token given");
+ assert_eq!(token, "t0k3n");
+ assert_eq!(homeserver, None);
+ assert_eq!(user_id, None);
+ }
+
+ #[tokio::test]
+ async fn resolve_credential_rejects_an_unknown_mode() {
+ assert!(
+ resolve_credential(&request("carrier-pigeon"))
+ .await
+ .is_err()
+ );
+ }
+}