Compare commits
2 changed files with 46 additions and 418 deletions
|
|
@ -1,17 +1,11 @@
|
||||||
// <LinkMatrixAccountForm> — writes a matrix account credential for one
|
// <LinkMatrixAccountForm> — writes a matrix account credential (token +
|
||||||
// agent into the swarm secret store, in one of two modes (mirrors
|
// optional homeserver) for one agent into the swarm secret store.
|
||||||
// swarm-controller's `PutMatrixAccountRequest`):
|
// PUTs `/api/hives/{hive}/agents/{agent}/matrix-accounts/{account}` per
|
||||||
// token — paste an already-obtained bearer token, optional homeserver.
|
// the contract atlas posted on the OpenBao swarm-secret-store adoption
|
||||||
// password — user id + password; swarm-controller logs into the
|
// issue: `{ token, homeserver? }` body, 200 on success, 400/503/500 on
|
||||||
// (required, here) homeserver itself and stores the token
|
// the documented failure arms — all handled generically via
|
||||||
// that comes back. The password is sent once, over this
|
// `readApiError`/`ApiErrorPanel`, same as every other form here, since
|
||||||
// PUT, straight to swarm-controller — never held here past
|
// the response is `problem+json` regardless of which arm fired.
|
||||||
// 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
|
// 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
|
// credential store shouldn't hand a secret back out anyway), so this is
|
||||||
|
|
@ -21,6 +15,12 @@
|
||||||
// mara's rulings on that issue — there's no assignment step here yet,
|
// mara's rulings on that issue — there's no assignment step here yet,
|
||||||
// just the write.
|
// 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
|
// Rendered inside a `Dialog` from `AgentsPage`, one per row — same
|
||||||
// mount shape as `CreateAgentForm`, own file for the same reason (a
|
// mount shape as `CreateAgentForm`, own file for the same reason (a
|
||||||
// multi-field form with real submit-state handling is more than
|
// multi-field form with real submit-state handling is more than
|
||||||
|
|
@ -31,12 +31,9 @@ import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||||
import { Panel } from "../ui/panel/Panel.js";
|
import { Panel } from "../ui/panel/Panel.js";
|
||||||
import { TextField } from "../ui/text-field/TextField.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 { Button } from "../ui/button/Button.js";
|
||||||
import "./LinkMatrixAccountForm.css";
|
import "./LinkMatrixAccountForm.css";
|
||||||
|
|
||||||
type Mode = "token" | "password";
|
|
||||||
|
|
||||||
// The store's own key-space, not `hive_types::Ident` — atlas's measured
|
// 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
|
// 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
|
// account name is a bare attrset key under `matrixAccounts`, so it
|
||||||
|
|
@ -48,18 +45,9 @@ const ACCOUNT_PATTERN = "[A-Za-z0-9_\\-]{1,63}";
|
||||||
type SubmitState =
|
type SubmitState =
|
||||||
| { status: "idle" }
|
| { status: "idle" }
|
||||||
| { status: "submitting" }
|
| { status: "submitting" }
|
||||||
// `userId` is set in password mode (the server resolves it from the
|
| { status: "done" }
|
||||||
// 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 };
|
| { status: "error"; problem: ProblemDetails };
|
||||||
|
|
||||||
const MODE_OPTIONS = [
|
|
||||||
{ value: "token", label: "paste a token" },
|
|
||||||
{ value: "password", label: "log in with a password" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function LinkMatrixAccountForm({
|
export function LinkMatrixAccountForm({
|
||||||
hive,
|
hive,
|
||||||
agent,
|
agent,
|
||||||
|
|
@ -68,10 +56,7 @@ export function LinkMatrixAccountForm({
|
||||||
agent: string;
|
agent: string;
|
||||||
}) {
|
}) {
|
||||||
const [account, setAccount] = useState("");
|
const [account, setAccount] = useState("");
|
||||||
const [mode, setMode] = useState<Mode>("token");
|
|
||||||
const [token, setToken] = useState("");
|
const [token, setToken] = useState("");
|
||||||
const [userId, setUserId] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [homeserver, setHomeserver] = useState("");
|
const [homeserver, setHomeserver] = useState("");
|
||||||
const [result, setResult] = useState<SubmitState>({ status: "idle" });
|
const [result, setResult] = useState<SubmitState>({ status: "idle" });
|
||||||
|
|
||||||
|
|
@ -84,43 +69,26 @@ export function LinkMatrixAccountForm({
|
||||||
{
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify(
|
body: JSON.stringify({
|
||||||
mode === "token"
|
token,
|
||||||
? {
|
// Omitted rather than sent empty — the contract marks it
|
||||||
mode,
|
// optional, and an empty string isn't "no homeserver", it's
|
||||||
token,
|
// a homeserver named "".
|
||||||
// Omitted rather than sent empty — the contract marks
|
homeserver: homeserver.trim() || undefined,
|
||||||
// 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) {
|
if (!r.ok) {
|
||||||
setResult({ status: "error", problem: await readApiError(r) });
|
setResult({ status: "error", problem: await readApiError(r) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const body = (await r.json().catch(() => ({}))) as {
|
setResult({ status: "done" });
|
||||||
user_id?: string;
|
// Token cleared on success — nothing left needing it in the form
|
||||||
};
|
// once the store holds it, and a secret sitting in a field after
|
||||||
setResult({ status: "done", userId: body.user_id });
|
// the action that used it is exactly the kind of stale-credential
|
||||||
// Secrets cleared on success — nothing left needing them in the
|
// risk the whole store exists to avoid. Account name kept, so the
|
||||||
// form once the store holds the derived credential, and a secret
|
// success message below reads against what actually landed.
|
||||||
// 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("");
|
setToken("");
|
||||||
setPassword("");
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setResult({ status: "error", problem: { detail: String(err) } });
|
setResult({ status: "error", problem: { detail: String(err) } });
|
||||||
}
|
}
|
||||||
|
|
@ -150,54 +118,20 @@ export function LinkMatrixAccountForm({
|
||||||
required
|
required
|
||||||
onInput={setAccount}
|
onInput={setAccount}
|
||||||
/>
|
/>
|
||||||
<SelectField
|
<TextField
|
||||||
id="matrix-account-mode"
|
id="matrix-account-token"
|
||||||
label="credential"
|
label="access token"
|
||||||
value={mode}
|
type="password"
|
||||||
options={MODE_OPTIONS}
|
value={token}
|
||||||
onChange={(v) => setMode(v as Mode)}
|
required
|
||||||
|
onInput={setToken}
|
||||||
/>
|
/>
|
||||||
{mode === "token" ? (
|
|
||||||
<TextField
|
|
||||||
id="matrix-account-token"
|
|
||||||
label="access token"
|
|
||||||
type="password"
|
|
||||||
value={token}
|
|
||||||
required
|
|
||||||
onInput={setToken}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<TextField
|
|
||||||
id="matrix-account-user-id"
|
|
||||||
label="matrix user id"
|
|
||||||
placeholder="@name:matrix.example.org"
|
|
||||||
value={userId}
|
|
||||||
required
|
|
||||||
onInput={setUserId}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
id="matrix-account-password"
|
|
||||||
label="password"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
required
|
|
||||||
onInput={setPassword}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<TextField
|
<TextField
|
||||||
id="matrix-account-homeserver"
|
id="matrix-account-homeserver"
|
||||||
// Optional in token mode (the hive falls back to its own
|
label="homeserver (optional)"
|
||||||
// default); required in password mode, since swarm-controller
|
|
||||||
// logs in against exactly this URL and has no fallback of its
|
|
||||||
// own to defer to — see `PutMatrixAccountRequest::homeserver`'s
|
|
||||||
// own doc for why the two modes differ here.
|
|
||||||
label={mode === "token" ? "homeserver (optional)" : "homeserver"}
|
|
||||||
type="url"
|
type="url"
|
||||||
value={homeserver}
|
value={homeserver}
|
||||||
placeholder="https://matrix.example.org"
|
placeholder="https://matrix.example.org"
|
||||||
required={mode === "password"}
|
|
||||||
onInput={setHomeserver}
|
onInput={setHomeserver}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -211,12 +145,6 @@ export function LinkMatrixAccountForm({
|
||||||
{result.status === "done" && (
|
{result.status === "done" && (
|
||||||
<p class="link-matrix-account-result-ok">
|
<p class="link-matrix-account-result-ok">
|
||||||
linked <strong>{account}</strong> to <strong>{agent}</strong>
|
linked <strong>{account}</strong> to <strong>{agent}</strong>
|
||||||
{result.userId ? (
|
|
||||||
<>
|
|
||||||
{" "}
|
|
||||||
— logged in as <strong>{result.userId}</strong>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{result.status === "error" && (
|
{result.status === "error" && (
|
||||||
|
|
|
||||||
|
|
@ -10,21 +10,11 @@
|
||||||
//! ⚠️ Store first, notify second, and the order cannot be swapped: a notice
|
//! ⚠️ 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
|
//! that overtakes its own write reaches a hive that reads nothing, and the
|
||||||
//! hive deliberately does not retry.
|
//! 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::Json;
|
||||||
use axum::extract::State;
|
use axum::extract::State;
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::Deserialize;
|
||||||
use swarm_queue_client::{CredentialNotice, credential_subject};
|
use swarm_queue_client::{CredentialNotice, credential_subject};
|
||||||
use swarm_secret_client::{SecretStore, matrix};
|
use swarm_secret_client::{SecretStore, matrix};
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
@ -40,58 +30,20 @@ use super::{AppState, error_problem, swarm_hive};
|
||||||
/// (`allowed_common_names`), so the two are deliberately different strings.
|
/// (`allowed_common_names`), so the two are deliberately different strings.
|
||||||
const CERT_ROLE: &str = "swarm-controller";
|
const CERT_ROLE: &str = "swarm-controller";
|
||||||
|
|
||||||
fn default_mode() -> String {
|
|
||||||
"token".to_owned()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The credential to store for one agent's external matrix account.
|
/// 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 {
|
pub struct PutMatrixAccountRequest {
|
||||||
/// `"token"` (default, back-compat) or `"password"`. Token mode stores
|
/// The access token. Never logged, and never returned by this route.
|
||||||
/// `token` as given; password mode logs into `homeserver` with `user_id`
|
token: String,
|
||||||
/// + `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<String>,
|
|
||||||
/// Password-mode only: the matrix user id (or bare localpart) to log in
|
|
||||||
/// as.
|
|
||||||
user_id: Option<String>,
|
|
||||||
/// Password-mode only. Never logged and never stored — only the token
|
|
||||||
/// `m.login.password` returns is.
|
|
||||||
password: Option<String>,
|
|
||||||
/// The account's homeserver, when it is not this swarm's own.
|
/// 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
|
/// 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
|
/// queue message, so a homeserver carried there would exist only in
|
||||||
/// flight, with nowhere to reconstruct it from on a re-delivery.
|
/// 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")]
|
#[schema(example = "https://matrix.example.org")]
|
||||||
homeserver: Option<String>,
|
homeserver: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `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<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Store an agent's external matrix account credential and notify its hive.
|
/// Store an agent's external matrix account credential and notify its hive.
|
||||||
///
|
///
|
||||||
/// Idempotent: the store keeps versions, so repeating a call replaces the
|
/// Idempotent: the store keeps versions, so repeating a call replaces the
|
||||||
|
|
@ -106,8 +58,8 @@ pub struct PutMatrixAccountResponse {
|
||||||
),
|
),
|
||||||
request_body = PutMatrixAccountRequest,
|
request_body = PutMatrixAccountRequest,
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "stored, and the hive has been told", body = PutMatrixAccountResponse),
|
(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, 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 = 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 = 503, description = "no swarm queue is wired up (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),
|
(status = 500, description = "the store write, the encode or the publish failed (problem+json)", body = String),
|
||||||
),
|
),
|
||||||
|
|
@ -117,7 +69,7 @@ pub async fn put_matrix_account(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
axum::extract::Path((hive, agent, account)): axum::extract::Path<(String, String, String)>,
|
axum::extract::Path((hive, agent, account)): axum::extract::Path<(String, String, String)>,
|
||||||
Json(req): Json<PutMatrixAccountRequest>,
|
Json(req): Json<PutMatrixAccountRequest>,
|
||||||
) -> Result<Json<PutMatrixAccountResponse>, problem_details::ProblemDetails> {
|
) -> Result<StatusCode, problem_details::ProblemDetails> {
|
||||||
// The queue first, so a deployment that has none answers 503 whatever the
|
// 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
|
// caller spelled — and before the store is touched, so a request that
|
||||||
// could never be delivered does not leave a credential behind.
|
// could never be delivered does not leave a credential behind.
|
||||||
|
|
@ -136,25 +88,6 @@ pub async fn put_matrix_account(
|
||||||
// charset is its rule to state, not this handler's to restate.
|
// charset is its rule to state, not this handler's to restate.
|
||||||
let secret_path = matrix::account_path(&agent, &account)
|
let secret_path = matrix::account_path(&agent, &account)
|
||||||
.map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.to_string()))?;
|
.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 is_reserved_account(&account) {
|
|
||||||
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.",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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) = resolve_credential(&req).await.map_err(|b| *b)?;
|
|
||||||
|
|
||||||
let store = SecretStore::from_env(CERT_ROLE).await.map_err(|e| {
|
let store = SecretStore::from_env(CERT_ROLE).await.map_err(|e| {
|
||||||
tracing::warn!(error = %e, "connecting to the swarm secret store failed");
|
tracing::warn!(error = %e, "connecting to the swarm secret store failed");
|
||||||
|
|
@ -164,8 +97,8 @@ pub async fn put_matrix_account(
|
||||||
.write(
|
.write(
|
||||||
&secret_path,
|
&secret_path,
|
||||||
&matrix::Credential {
|
&matrix::Credential {
|
||||||
value: token,
|
value: req.token,
|
||||||
homeserver,
|
homeserver: req.homeserver,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|
@ -207,238 +140,5 @@ pub async fn put_matrix_account(
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
tracing::info!(%subject, %hive, %agent, %account, "credential stored; hive notified");
|
tracing::info!(%subject, %hive, %agent, %account, "credential stored; hive notified");
|
||||||
Ok(Json(PutMatrixAccountResponse { user_id }))
|
Ok(StatusCode::OK)
|
||||||
}
|
|
||||||
|
|
||||||
/// 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<String>), &'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<PasswordFields<'_>, &'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<String>, Option<String>), Box<problem_details::ProblemDetails>> {
|
|
||||||
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 `<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 = 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()))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue