fix(auth): revoke desktop sessions safely

This commit is contained in:
2026-07-10 08:57:00 -04:00
parent 94afa23a69
commit 2f346abaea
21 changed files with 1375 additions and 455 deletions
+2
View File
@@ -18,6 +18,8 @@ use crate::{
vault_bootstrap::SyncVaultBootstrapRequest,
};
mod session;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const USER_AGENT: &str = concat!("ELY Browser/", env!("CARGO_PKG_VERSION"));
@@ -0,0 +1,52 @@
use serde::Deserialize;
use super::{SyncApiClient, read_json_from_response};
use crate::error::SyncClientError;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct SessionLogoutDocument {
version: u32,
signed_out: bool,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct AuthErrorDocument {
error: String,
}
impl SyncApiClient {
pub fn sign_out(&self) -> Result<(), SyncClientError> {
let endpoint = self.endpoint("/api/session/logout");
let response = self
.agent
.post(&endpoint)
.set("Authorization", &format!("Bearer {}", self.bearer.as_str()))
.call();
match response {
Ok(response) => {
let document =
read_json_from_response::<SessionLogoutDocument>(&endpoint, response)?;
if document.version != 1 || !document.signed_out {
return Err(SyncClientError::SessionLogoutInvalid);
}
Ok(())
}
Err(ureq::Error::Status(status, response)) => {
let body = response.into_string().unwrap_or_default();
if status == 401 && logout_is_already_complete(&body) {
return Ok(());
}
Err(SyncClientError::HttpStatus { endpoint, status, body })
}
Err(source) => Err(SyncClientError::Http { endpoint, source: Box::new(source) }),
}
}
}
fn logout_is_already_complete(body: &str) -> bool {
serde_json::from_str::<AuthErrorDocument>(body).is_ok_and(|document| {
matches!(document.error.as_str(), "session_not_found" | "session_expired")
})
}
+111
View File
@@ -12,6 +12,83 @@ use crate::{
type TestServer = JoinHandle<std::io::Result<()>>;
#[test]
fn sign_out_posts_the_bearer_and_validates_success() -> Result<(), Box<dyn Error>> {
let (base_url, server) = spawn_logout_server("200 OK", r#"{"version":1,"signed_out":true}"#)?;
let client = SyncApiClient::new(
ApiClientConfig::custom(base_url, "auto"),
BearerToken::new("a".repeat(64))?,
)?;
client.sign_out()?;
join_server(server)
}
#[test]
fn sign_out_accepts_already_ended_sessions() -> Result<(), Box<dyn Error>> {
for code in ["session_not_found", "session_expired"] {
let body = format!(r#"{{"error":"{code}"}}"#);
let (base_url, server) = spawn_logout_server("401 Unauthorized", &body)?;
let client = SyncApiClient::new(
ApiClientConfig::custom(base_url, "auto"),
BearerToken::new("a".repeat(64))?,
)?;
client.sign_out()?;
join_server(server)?;
}
Ok(())
}
#[test]
fn sign_out_preserves_retryable_failures() -> Result<(), Box<dyn Error>> {
let failures = [
("401 Unauthorized", r#"{"error":"authorization_invalid"}"#, 401),
("401 Unauthorized", r#"{"error":"session_not_found","extra":true}"#, 401),
("403 Forbidden", r#"{"error":"session_not_found"}"#, 403),
("500 Internal Server Error", r#"{"error":"session_logout_failed"}"#, 500),
];
for (status_line, body, expected_status) in failures {
let (base_url, server) = spawn_logout_server(status_line, body)?;
let client = SyncApiClient::new(
ApiClientConfig::custom(base_url, "auto"),
BearerToken::new("a".repeat(64))?,
)?;
let error = match client.sign_out() {
Ok(()) => return Err("logout failure was treated as success".into()),
Err(error) => error,
};
assert!(matches!(
error,
crate::SyncClientError::HttpStatus { status, .. } if status == expected_status
));
join_server(server)?;
}
for body in [r#"{"version":1,"signed_out":false}"#, r#"{"version":2,"signed_out":true}"#] {
let (base_url, server) = spawn_logout_server("200 OK", body)?;
let client = SyncApiClient::new(
ApiClientConfig::custom(base_url, "auto"),
BearerToken::new("a".repeat(64))?,
)?;
assert!(matches!(client.sign_out(), Err(crate::SyncClientError::SessionLogoutInvalid)));
join_server(server)?;
}
for body in ["{", r#"{"version":1,"signed_out":true,"extra":true}"#, r#"{"version":1}"#] {
let (base_url, server) = spawn_logout_server("200 OK", body)?;
let client = SyncApiClient::new(
ApiClientConfig::custom(base_url, "auto"),
BearerToken::new("a".repeat(64))?,
)?;
assert!(matches!(client.sign_out(), Err(crate::SyncClientError::Json { .. })));
join_server(server)?;
}
Ok(())
}
#[test]
fn upload_parses_structured_snapshot_head_conflict() -> Result<(), Box<dyn Error>> {
let (base_url, server) = spawn_conflict_server()?;
@@ -99,6 +176,40 @@ fn spawn_conflict_server() -> Result<(String, TestServer), Box<dyn Error>> {
Ok((format!("http://{address}"), server))
}
fn spawn_logout_server(
status_line: &'static str,
body: &str,
) -> Result<(String, TestServer), Box<dyn Error>> {
let listener = TcpListener::bind("127.0.0.1:0")?;
let address = listener.local_addr()?;
let body = body.to_string();
let server = thread::spawn(move || -> std::io::Result<()> {
let (mut stream, _) = listener.accept()?;
let mut request = Vec::new();
let mut chunk = [0_u8; 1024];
while !request.windows(4).any(|window| window == b"\r\n\r\n") {
let read = stream.read(&mut chunk)?;
if read == 0 || request.len() + read > 8192 {
return Err(std::io::Error::other("logout request headers are incomplete"));
}
request.extend_from_slice(&chunk[..read]);
}
let request = String::from_utf8_lossy(&request);
if !request.starts_with("POST /api/session/logout HTTP/1.1\r\n")
|| !request.contains(&format!("Authorization: Bearer {}\r\n", "a".repeat(64)))
{
return Err(std::io::Error::other("logout request contract mismatch"));
}
let response = format!(
"HTTP/1.1 {status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
stream.write_all(response.as_bytes())?;
stream.flush()
});
Ok((format!("http://{address}"), server))
}
fn join_server(server: TestServer) -> Result<(), Box<dyn Error>> {
match server.join() {
Ok(result) => result.map_err(Into::into),
+3
View File
@@ -11,6 +11,9 @@ pub enum SyncClientError {
#[error("Bearer credential storage is unavailable: {0}")]
BearerCredentialStorage(String),
#[error("Session logout response is invalid")]
SessionLogoutInvalid,
#[error("HTTP request failed for {endpoint}: {source}")]
Http {
endpoint: String,