//! The account side of the sync server: checking an address, finishing a //! sign-in, and managing the account's devices and libraries. //! //! The sign-in itself happens in the browser, on the server's own pages (see //! the server's `auth` module); the app only starts it and redeems the //! single-use code it gets back. What the app proves when redeeming is that it //! is the one that started the sign-in — PKCE, RFC 7636 — which is what makes //! it safe for that code to travel through a browser and a URL scheme. use base64::Engine; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use super::remote::HttpRemote; use crate::error::{CoreError, Result}; use crate::http::{agent, USER_AGENT}; /// What a server says about itself, before anyone is signed in. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ServerInfo { pub server: String, pub api: u32, #[serde(default)] pub local_auth: bool, /// The identity provider's display name, when single sign-on is on. #[serde(default)] pub oidc: Option, /// Whether an account can keep several named libraries. Servers from /// before then keep one, at `/api/library`. #[serde(default)] pub libraries: bool, } /// The newest server API this build speaks. pub const API_VERSION: u32 = 1; /// Tidy a server address as typed: trim it, drop trailing slashes, and assume /// `https://` when no scheme is given. pub fn normalize_server_url(input: &str) -> String { let s = input.trim().trim_end_matches('/'); if s.contains("://") { s.to_string() } else { format!("https://{s}") } } /// Confirm `server_url` is an mtgc sync server this build can talk to, before /// sending the user's browser there. pub fn server_info(server_url: &str) -> Result { let url = format!("{server_url}/api/info"); let info: ServerInfo = agent() .get(&url) .set("User-Agent", USER_AGENT) .set("Accept", "application/json") .call() .map_err(|e| CoreError::Http(format!("couldn't reach {server_url}: {e}")))? .into_json() .map_err(|_| not_a_server(server_url))?; if info.server != "mtgc" { return Err(not_a_server(server_url)); } if info.api > API_VERSION { return Err(CoreError::Other( "that sync server is newer than this app — update the app first".into(), )); } Ok(info) } fn not_a_server(url: &str) -> CoreError { CoreError::Other(format!("{url} doesn't look like an mtgc sync server")) } /// A PKCE verifier and its S256 challenge. #[derive(Debug, Clone)] pub struct Pkce { pub verifier: String, pub challenge: String, } impl Pkce { pub fn new() -> Self { let verifier = random_token(); let challenge = Self::challenge_for(&verifier); Self { verifier, challenge } } /// base64url(SHA-256(verifier)), unpadded — RFC 7636 §4.2. pub fn challenge_for(verifier: &str) -> String { base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())) } } impl Default for Pkce { fn default() -> Self { Self::new() } } /// 256 random bits, base64url: 43 characters, which is also exactly the /// shortest PKCE verifier RFC 7636 allows. pub fn random_token() -> String { let mut buf = [0u8; 32]; getrandom::fill(&mut buf).expect("the OS random number generator failed"); base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buf) } /// The browser address that starts a sign-in. pub fn start_url( server_url: &str, redirect_uri: &str, pkce: &Pkce, state: &str, device_name: &str, ) -> String { let query: String = url_encode_pairs(&[ ("redirect_uri", redirect_uri), ("code_challenge", &pkce.challenge), ("code_challenge_method", "S256"), ("state", state), ("device_name", device_name), ]); format!("{server_url}/auth/start?{query}") } fn url_encode_pairs(pairs: &[(&str, &str)]) -> String { pairs .iter() .map(|(k, v)| format!("{k}={}", percent_encode(v))) .collect::>() .join("&") } /// Percent-encode everything but RFC 3986's unreserved characters. fn percent_encode(s: &str) -> String { let mut out = String::with_capacity(s.len()); for b in s.bytes() { match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => out.push(b as char), _ => out.push_str(&format!("%{b:02X}")), } } out } /// The account a device is signed in to. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AccountUser { /// Stable id: what a stored merge base is keyed to. pub id: String, pub username: String, #[serde(default)] pub display_name: String, #[serde(default)] pub email: String, /// "local" or "oidc". #[serde(default)] pub method: String, } /// The result of a completed sign-in. #[derive(Debug, Clone, Deserialize)] pub struct SignedIn { pub token: String, pub device_id: i64, pub user: AccountUser, } /// Trade the code from the browser, with the verifier only this app holds, for /// this device's token. pub fn redeem_code(server_url: &str, code: &str, verifier: &str) -> Result { let resp = agent() .post(&format!("{server_url}/auth/token")) .set("User-Agent", USER_AGENT) .send_json(serde_json::json!({ "code": code, "code_verifier": verifier })); match resp { Ok(r) => Ok(r.into_json()?), Err(ureq::Error::Status(400, _)) => Err(CoreError::Other( "the sign-in expired before it could be completed — try again".into(), )), Err(e) => Err(e.into()), } } /// One of an account's signed-in devices. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DeviceInfo { pub id: i64, pub name: String, pub created_at: i64, pub last_seen_at: i64, /// Whether it is the device asking. pub current: bool, } /// One of an account's libraries on the server. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RemoteLibrary { pub id: String, pub name: String, /// The library `/api/library` serves — the one the account started with. #[serde(default)] pub default: bool, #[serde(default)] pub version: u64, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Me { pub user: AccountUser, pub devices: Vec, } impl HttpRemote { pub fn me(&self) -> Result { Ok(self.request("GET", "/api/me").call().map_err(super::remote::http_error)?.into_json()?) } /// Sign another of the account's devices out. pub fn revoke_device(&self, id: i64) -> Result<()> { self.request("DELETE", &format!("/api/devices/{id}")) .call() .map_err(super::remote::http_error)?; Ok(()) } /// The account's libraries, the default first. pub fn list_libraries(&self) -> Result> { Ok(self.request("GET", "/api/libraries").call().map_err(super::remote::http_error)?.into_json()?) } /// Start a new, empty library on the server. pub fn create_library(&self, name: &str) -> Result { Ok(self .request("POST", "/api/libraries") .send_json(serde_json::json!({ "name": name })) .map_err(super::remote::http_error)? .into_json()?) } pub fn rename_library(&self, id: &str, name: &str) -> Result { Ok(self .request("PATCH", &format!("/api/libraries/{}", super::remote::url_path_segment(id))) .send_json(serde_json::json!({ "name": name })) .map_err(super::remote::http_error)? .into_json()?) } /// Delete a library from the server. Devices keep their own copies. pub fn delete_library(&self, id: &str) -> Result<()> { self.request("DELETE", &format!("/api/libraries/{}", super::remote::url_path_segment(id))) .call() .map_err(super::remote::http_error)?; Ok(()) } /// Sign this device out on the server, so its token stops working even if /// a copy of it survives somewhere. pub fn logout(&self) -> Result<()> { self.request("POST", "/auth/logout").call().map_err(super::remote::http_error)?; Ok(()) } } #[cfg(test)] mod tests { use super::*; #[test] fn server_addresses_are_tidied() { assert_eq!(normalize_server_url(" mtgc.example.com/ "), "https://mtgc.example.com"); assert_eq!(normalize_server_url("http://10.0.2.2:8080//"), "http://10.0.2.2:8080"); } #[test] fn pkce_matches_the_rfc_7636_example() { // Appendix B of the RFC. assert_eq!( Pkce::challenge_for("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"), "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" ); let p = Pkce::new(); assert_eq!(p.verifier.len(), 43); assert_ne!(p.verifier, Pkce::new().verifier); } #[test] fn the_start_url_carries_everything_encoded() { let p = Pkce { verifier: "v".into(), challenge: "c-_".into() }; let url = start_url( "https://m.example", "http://127.0.0.1:5000/callback", &p, "s t", "Garrett's phone", ); assert_eq!( url, "https://m.example/auth/start?redirect_uri=http%3A%2F%2F127.0.0.1%3A5000%2Fcallback\ &code_challenge=c-_&code_challenge_method=S256&state=s%20t&device_name=Garrett%27s%20phone" ); } }