fix(auth): revoke desktop sessions safely
This commit is contained in:
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user