Sign in the way the homeserver actually allows

Only `m.login.password` was implemented, so a homeserver configured for
single sign-on - most corporate ones, and where the spec is heading with
OIDC - could not be signed into at all.

The flows are read first, because the answer decides what to ask for: a
server offering only SSO has no password to take, and a form demanding one
there is a form nobody can complete.

SSO itself is the shape the spec and a desktop app leave open: the
homeserver sends the browser back to an address of our choosing carrying a
one-time token, and that token is exchanged for a real one. So this listens
on loopback for exactly one request, answers the browser with a page saying
it can be closed, and finishes the sign-in with what it carried.

Loopback rather than a custom scheme: it needs no registration with the
desktop, behaves the same on every platform, and cannot be claimed by
another application - the kernel picks the port a moment before it is used
and nothing else knows it.

The URL is handed to the client rather than opened here. It belongs in the
real browser, where somebody's password manager, passkey and second factor
already live.

An SSO account has no stored password, which is a real difference rather
than an omission: anything password-gated - publishing cross-signing keys,
removing a device - has to ask the homeserver for its own interactive auth
instead.

Tested against the loopback handler end to end, favicon request and all,
and read live from three homeservers: password only, password with SSO, and
one that would not resolve.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-04 18:16:58 -04:00
co-authored by Claude Opus 5
parent d3ce600887
commit 9882812370
4 changed files with 259 additions and 0 deletions
+40
View File
@@ -45,6 +45,46 @@ pub async fn login(homeserver_url: &str, username: &str, password: &str, device_
Ok(LoginResult { user_id, access_token, device_id })
}
/// Which ways a homeserver will let somebody sign in.
///
/// Asked before anything is typed, because the answer decides what to ask
/// for: a server with only `m.login.sso` has no password to take, and a form
/// that demands one there is a form nobody can complete.
pub async fn login_flows(homeserver_url: &str) -> Result<Vec<String>> {
let url = format!("{}/_matrix/client/v3/login", homeserver_url.trim_end_matches('/'));
let resp = super::http::get_json_anonymous(&url).await.context("asking how to sign in")?;
Ok(resp["flows"]
.as_array()
.into_iter()
.flatten()
.filter_map(|flow| flow["type"].as_str().map(|s| s.to_string()))
.collect())
}
/// Finishes an SSO sign-in with the one-time token the browser came back
/// with.
///
/// The same endpoint as a password login, with the type that says a third
/// party already did the identifying. `device_id` is reused for exactly the
/// reason the module doc gives - the identity has to survive.
pub async fn login_with_token(homeserver_url: &str, token: &str, device_id: Option<&str>) -> Result<LoginResult> {
let url = format!("{}/_matrix/client/v3/login", homeserver_url.trim_end_matches('/'));
let mut body = json!({
"type": "m.login.token",
"token": token,
"initial_device_display_name": "nobilis",
});
if let Some(device_id) = device_id {
body["device_id"] = json!(device_id);
}
let resp = post_json(&url, None, body).await.context("finishing the sign-in")?;
Ok(LoginResult {
user_id: resp["user_id"].as_str().context("login response missing user_id")?.to_string(),
access_token: resp["access_token"].as_str().context("login response missing access_token")?.to_string(),
device_id: resp["device_id"].as_str().context("login response missing device_id")?.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
+8
View File
@@ -111,6 +111,14 @@ pub async fn post_json(url: &str, token: Option<&str>, body: Value) -> Result<Va
handle_response(resp).await
}
/// A GET with no credential, for the handful of endpoints that take none -
/// asking a homeserver how it lets people sign in is the first thing that
/// happens, before there is anybody to be.
pub async fn get_json_anonymous(url: &str) -> Result<Value> {
let resp = http_client().get(url).send().await.context("request failed")?;
handle_response(resp).await
}
pub async fn get_json(url: &str, token: &str) -> Result<Value> {
let resp = http_client().get(url).bearer_auth(token).send().await.context("request failed")?;
handle_response(resp).await
+181
View File
@@ -3125,6 +3125,143 @@ async fn joined_member_ids(base: &str, access_token: &str, room_id: &str) -> Res
/// async-kickoff shape. Progress/result arrive via matrixLoginStatus/
/// matrixLoginResult events tagged with `login_id`, not the RPC response
/// (see rpc/methods.rs's addMatrixAccount).
/// Which ways a homeserver will let somebody sign in, resolving delegation
/// first - the address somebody types is often not where the API lives.
pub async fn login_flows(homeserver_url: &str) -> Result<Vec<String>> {
let resolved = http::resolve_homeserver(homeserver_url).await?;
auth::login_flows(&resolved).await
}
/// Signs in through the homeserver's own web login.
///
/// The shape is fixed by the spec and by what a desktop app can do: the
/// homeserver sends the browser back to a URL of our choosing with a one-time
/// token, and that token is exchanged for a real one. So this listens on
/// loopback for exactly one request, hands the browser a page saying it can
/// be closed, and finishes the login with what it carried.
///
/// Loopback rather than a custom scheme because it needs no registration with
/// the desktop, works the same on every platform, and cannot be claimed by
/// another application: the port is chosen by the kernel a moment before it
/// is used, and nothing else knows it.
pub fn start_sso_login(state: AppState, login_id: String, homeserver_url: String, provider: Option<String>) {
tokio::spawn(async move {
let result = std::panic::AssertUnwindSafe(try_sso_login(&state, &login_id, &homeserver_url, provider.as_deref())).catch_unwind().await;
let error = match result {
Ok(Ok(())) => return,
Ok(Err(e)) => format!("{e:#}"),
Err(_) => "internal error (see nobilis logs)".to_string(),
};
tracing::warn!("matrix sso login[{login_id}]: {error}");
state.events.emit("matrixLoginResult", serde_json::json!({ "loginId": login_id, "success": false, "error": error }));
});
}
/// How long to hold the loopback listener open waiting for the browser.
const SSO_TIMEOUT: Duration = Duration::from_secs(300);
async fn try_sso_login(state: &AppState, login_id: &str, homeserver_url: &str, provider: Option<&str>) -> Result<()> {
state.events.emit("matrixLoginStatus", serde_json::json!({ "loginId": login_id, "detail": "finding the server..." }));
let homeserver_url = http::resolve_homeserver(homeserver_url).await?;
let base = homeserver_url.trim_end_matches('/');
let flows = auth::login_flows(&homeserver_url).await.unwrap_or_default();
if !flows.iter().any(|flow| flow == "m.login.sso" || flow == "m.login.token") {
anyhow::bail!("this homeserver does not offer single sign-on");
}
// Bound before the URL is built, because the URL has to contain the port.
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.context("opening a port for the sign-in to come back to")?;
let port = listener.local_addr().context("reading the port")?.port();
let redirect = format!("http://127.0.0.1:{port}/");
let url = match provider.filter(|p| !p.is_empty()) {
Some(provider) => format!(
"{base}/_matrix/client/v3/login/sso/redirect/{}?redirectUrl={}",
url::form_urlencoded::byte_serialize(provider.as_bytes()).collect::<String>(),
url::form_urlencoded::byte_serialize(redirect.as_bytes()).collect::<String>()
),
None => format!(
"{base}/_matrix/client/v3/login/sso/redirect?redirectUrl={}",
url::form_urlencoded::byte_serialize(redirect.as_bytes()).collect::<String>()
),
};
// The client opens this; the daemon has no browser and should not pretend
// to. Sent as a status rather than returned, because the RPC that started
// this answered long before the person had finished typing a password
// into somebody else's login page.
state.events.emit(
"matrixLoginStatus",
serde_json::json!({ "loginId": login_id, "detail": "waiting for the browser...", "url": url }),
);
let token = tokio::time::timeout(SSO_TIMEOUT, wait_for_sso_token(listener))
.await
.map_err(|_| anyhow::anyhow!("the sign-in was not finished in time"))?
.context("waiting for the browser to come back")?;
state.events.emit("matrixLoginStatus", serde_json::json!({ "loginId": login_id, "detail": "logging in..." }));
let login = auth::login_with_token(&homeserver_url, &token, None).await.context("finishing the sign-in")?;
let config = MatrixAccountConfig {
homeserver_url: homeserver_url.to_string(),
user_id: login.user_id,
// No password to keep, and that is a real difference rather than an
// omission: anything that needs one - publishing cross-signing keys,
// removing a device - will have to ask the homeserver for its own
// interactive auth instead.
password: String::new(),
access_token: login.access_token,
device_id: login.device_id,
next_batch: None,
display_name: None,
};
let saved = state.accounts.add_matrix(config)?;
let account = crate::accounts::matrix_account_to_json(&saved, "connecting", false);
spawn(state.clone(), saved);
state.events.emit("matrixLoginResult", serde_json::json!({ "loginId": login_id, "success": true, "account": account }));
Ok(())
}
/// Waits for the browser to arrive with a token, and tells the person they
/// can close the tab.
async fn wait_for_sso_token(listener: tokio::net::TcpListener) -> Result<String> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
loop {
let (mut socket, _) = listener.accept().await.context("accepting the browser's request")?;
let mut buffer = [0u8; 2048];
let read = socket.read(&mut buffer).await.unwrap_or(0);
let request = String::from_utf8_lossy(&buffer[..read]).to_string();
let token = request
.lines()
.next()
.and_then(|line| line.split_whitespace().nth(1))
.and_then(|target| {
let query = target.split_once('?')?.1;
url::form_urlencoded::parse(query.as_bytes())
.find(|(key, _)| key == "loginToken")
.map(|(_, value)| value.into_owned())
});
let page = match &token {
Some(_) => "<!doctype html><meta charset=utf-8><title>Signed in</title><body style=\"font-family:sans-serif;padding:2rem\"><h1>Signed in</h1><p>You can close this tab and go back to moho.</p>",
None => "<!doctype html><meta charset=utf-8><title>Nothing to sign in with</title><body style=\"font-family:sans-serif;padding:2rem\"><h1>Nothing to sign in with</h1><p>The homeserver sent the browser back without a login token.</p>",
};
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{page}",
page.len()
);
let _ = socket.write_all(response.as_bytes()).await;
let _ = socket.shutdown().await;
// A browser asking for /favicon.ico is not the answer; keep waiting
// for the one that carries a token.
if let Some(token) = token {
return Ok(token);
}
}
}
pub fn start_login(state: AppState, login_id: String, homeserver_url: String, username: String, password: String) {
tokio::spawn(async move {
let result = std::panic::AssertUnwindSafe(try_login(&state, &login_id, &homeserver_url, &username, &password)).catch_unwind().await;
@@ -3201,6 +3338,50 @@ async fn try_login(state: &AppState, login_id: &str, homeserver_url: &str, usern
Ok(())
}
#[cfg(test)]
mod sso_tests {
/// The browser comes back to a port this process opened a moment ago,
/// carrying a one-time token in the query string. Everything about the
/// sign-in hangs off reading that correctly, and a hand-rolled HTTP
/// handler is exactly the place to get it wrong.
#[tokio::test]
async fn reads_the_token_the_browser_brings_back() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind");
let port = listener.local_addr().expect("addr").port();
let waiting = tokio::spawn(super::wait_for_sso_token(listener));
// A browser asking for the icon first, which must not be mistaken for
// the answer - this is why the handler loops rather than taking the
// first request it sees.
let mut noise = tokio::net::TcpStream::connect(("127.0.0.1", port)).await.expect("connect");
noise.write_all(b"GET /favicon.ico HTTP/1.1\r\nHost: localhost\r\n\r\n").await.expect("write");
drop(noise);
let mut browser = tokio::net::TcpStream::connect(("127.0.0.1", port)).await.expect("connect");
browser
.write_all(b"GET /?loginToken=syt_abc123 HTTP/1.1\r\nHost: localhost\r\n\r\n")
.await
.expect("write");
// And the page it is answered with, so nobody is left looking at a
// browser error after a sign-in that worked.
let mut answer = Vec::new();
browser.read_to_end(&mut answer).await.expect("read");
let answer = String::from_utf8_lossy(&answer);
assert!(answer.starts_with("HTTP/1.1 200 OK"), "{answer}");
assert!(answer.contains("You can close this tab"), "{answer}");
let token = tokio::time::timeout(std::time::Duration::from_secs(5), waiting)
.await
.expect("in time")
.expect("task")
.expect("token");
assert_eq!(token, "syt_abc123");
}
}
#[cfg(test)]
mod tests {
+30
View File
@@ -2190,6 +2190,36 @@ pub async fn dispatch(
(Some(serde_json::json!({ "loginId": login_id })), None)
}
// How a homeserver lets people sign in, asked before anything is
// typed: a server offering only SSO has no password to take, and a
// form demanding one there is a form nobody can complete.
"matrixLoginFlows" => {
let Some(homeserver_url) = p_str_opt(params, "homeserverUrl") else {
return (None, Some("matrixLoginFlows requires \"homeserverUrl\"".to_string()));
};
match backend::matrix::login_flows(homeserver_url).await {
Ok(flows) => (Some(serde_json::json!({ "flows": flows })), None),
Err(e) => (None, Some(format!("{e:#}"))),
}
}
// Signing in through the homeserver's own web login. Answers with the
// login id straight away; the URL to open arrives as a status event a
// moment later, once the port it has to point at exists.
"addMatrixAccountSso" => {
let Some(homeserver_url) = p_str_opt(params, "homeserverUrl") else {
return (None, Some("addMatrixAccountSso requires \"homeserverUrl\"".to_string()));
};
let login_id = format!("matrix-login-{}", crate::model::next_message_id());
backend::matrix::start_sso_login(
state.clone(),
login_id.clone(),
homeserver_url.to_string(),
p_str_opt(params, "provider").map(|s| s.to_string()),
);
(Some(serde_json::json!({ "loginId": login_id })), None)
}
// Adds a Kick account, signed in or not.
//
// The token is optional and that is the interesting part: Kick's chat