//! 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 { 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::() .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 { 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::() .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"); } }