Files
DarkRoom/core/dr-sync-nextcloud/src/auth.rs
T
dtourolle 4d78041d1d
Build and test / Desktop (Linux) (push) Failing after 1m8s
Build and test / Android (aarch64) (push) Failing after 2s
Build and test / Layer separation (push) Canceled after 23s
Traceability / Requirement traces (push) Failing after 59s
Many imorovments
2026-08-12 22:16:15 +02:00

188 lines
5.6 KiB
Rust

//! Login Flow v2 (FR-NC-1).
//!
//! The app never sees the user's password. It asks the server for a login URL,
//! opens that in the **system browser**, and polls until the server hands back
//! an app password scoped to this device.
use std::time::Duration;
use dr_sync::RemoteError;
use serde::{Deserialize, Serialize};
/// The flow's poll token is valid for 20 minutes.
const FLOW_TIMEOUT: Duration = Duration::from_secs(20 * 60);
const POLL_INTERVAL: Duration = Duration::from_secs(2);
/// What the server returns when a flow is started.
#[derive(Debug, Clone, Deserialize)]
pub struct LoginFlow {
/// Open this in the system browser — never an embedded webview, which
/// would defeat the point of not handling the password.
#[serde(rename = "login")]
pub login_url: String,
#[serde(rename = "poll")]
pub poll: PollInfo,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PollInfo {
pub token: String,
pub endpoint: String,
}
/// Credentials issued at the end of the flow.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AppCredentials {
pub server: String,
#[serde(rename = "loginName")]
pub login_name: String,
/// Device-scoped and individually revocable — not the user's password.
#[serde(rename = "appPassword")]
pub app_password: String,
}
/// TRACES: FR-NC-1 | M-1
/// Begin a login flow.
///
/// The `User-Agent` names the resulting app password in the user's security
/// settings, so it should identify the device for per-device revocation.
pub async fn begin(
client: &reqwest::Client,
server: &str,
user_agent: &str,
) -> Result<LoginFlow, RemoteError> {
let url = format!("{}/index.php/login/v2", server.trim_end_matches('/'));
let resp = client
.post(&url)
.header(reqwest::header::USER_AGENT, user_agent)
.send()
.await
.map_err(super::map_send_error)?;
if !resp.status().is_success() {
return Err(RemoteError::Server {
status: resp.status().as_u16(),
detail: "login flow could not be started".into(),
});
}
resp.json::<LoginFlow>()
.await
.map_err(|e| RemoteError::Protocol(e.to_string()))
}
/// Poll until the user finishes authenticating in the browser.
///
/// The server returns 404 while pending and 200 exactly once — so a dropped
/// success response means restarting the flow, and the result must be
/// persisted immediately.
pub async fn poll(
client: &reqwest::Client,
flow: &LoginFlow,
) -> Result<AppCredentials, RemoteError> {
let deadline = std::time::Instant::now() + FLOW_TIMEOUT;
while std::time::Instant::now() < deadline {
let resp = client
.post(&flow.poll.endpoint)
// One field; encoding it by hand avoids pulling in reqwest's
// form feature for a single call.
.header(
reqwest::header::CONTENT_TYPE,
"application/x-www-form-urlencoded",
)
.body(format!("token={}", urlencode(&flow.poll.token)))
.send()
.await
.map_err(super::map_send_error)?;
match resp.status().as_u16() {
200 => {
return resp
.json::<AppCredentials>()
.await
.map_err(|e| RemoteError::Protocol(e.to_string()))
}
// Still waiting for the user.
404 => tokio::time::sleep(POLL_INTERVAL).await,
s => {
return Err(RemoteError::Server {
status: s,
detail: "unexpected status while polling".into(),
})
}
}
}
Err(RemoteError::AuthFailed)
}
/// Percent-encode a form value.
fn urlencode(s: &str) -> String {
s.bytes()
.map(|b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
(b as char).to_string()
}
_ => format!("%{b:02X}"),
})
.collect()
}
/// TRACES: FR-NC-1 | M-4
/// Revoke the app password on logout (FR-NC-1).
pub async fn revoke(
client: &reqwest::Client,
server: &str,
login: &str,
password: &str,
) -> Result<(), RemoteError> {
let url = format!(
"{}/ocs/v2.php/core/apppassword",
server.trim_end_matches('/')
);
client
.delete(&url)
.basic_auth(login, Some(password))
.header("OCS-APIRequest", "true")
.send()
.await
.map_err(super::map_send_error)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn login_flow_response_deserialises() {
// The shape Nextcloud actually returns.
let json = r#"{
"poll": {"token": "abc", "endpoint": "https://cloud.example/login/v2/poll"},
"login": "https://cloud.example/login/v2/flow/xyz"
}"#;
let flow: LoginFlow = serde_json::from_str(json).unwrap();
assert_eq!(flow.poll.token, "abc");
assert!(flow.login_url.contains("/login/v2/flow/"));
}
#[test]
fn form_values_are_encoded() {
assert_eq!(urlencode("abc123"), "abc123");
assert_eq!(urlencode("a b+c"), "a%20b%2Bc");
}
#[test]
fn credentials_deserialise_with_camel_case_keys() {
let json = r#"{
"server": "https://cloud.example",
"loginName": "duncan",
"appPassword": "secret-token"
}"#;
let c: AppCredentials = serde_json::from_str(json).unwrap();
assert_eq!(c.login_name, "duncan");
assert_eq!(c.app_password, "secret-token");
}
}