Files
mtgc/crates/mtgc-core/src/sync/account.rs

259 lines
7.8 KiB
Rust
Raw Normal View History

Replace folder sync with an HTTP sync server, keeping the app offline-first Folder sync left too much to the user's sync tool: platform file access (the Android SAF plugin), choosing the document, merging conflict copies — and its merge base could fall out of step with the document (absorbing conflict copies, "forget", picking a new Android folder), reading every difference as a deletion. The new mtgc-server crate stores one versioned library per user and accepts a write only when it names the version it replaces. It never merges: devices still do that with merge3, through a new sync cycle over a `Remote` trait in mtgc-core. The base advances only on knowledge — to what was read, or to a write the server confirmed — and is stored with the server and account it belongs to, so a different account starts from an empty ancestor instead of deleting. The working copy is only rewritten under the app-state lock, and sync commands now run off the main thread. Sign-in happens in the browser on the server's pages, via the owner's OIDC provider and/or local username/password accounts (Argon2id, managed with `mtgc-server user ...`), and returns a per-device revocable token to the app by PKCE-protected code: a loopback port on desktop, the mtgc:// scheme on Android. The Info tab gains sign-in, the first-sync combine/replace choice, the account's devices, and JSON export/import; the top bar shows sync state. Includes a Dockerfile, Compose/Caddy example, and scripts to build and push ${DOCKER_REGISTRY}/mtgc/sync-server.
2026-10-08 22:32:08 -05:00
//! The account side of the sync server: checking an address, finishing a
//! sign-in, and managing the account's devices.
//!
//! 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<String>,
}
/// 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<ServerInfo> {
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::<Vec<_>>()
.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<SignedIn> {
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,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Me {
pub user: AccountUser,
pub devices: Vec<DeviceInfo>,
}
impl HttpRemote {
pub fn me(&self) -> Result<Me> {
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(())
}
/// 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"
);
}
}