The app can keep any number of named libraries and switch between them from the top bar or Info → Libraries. Each can sync to the account's other devices or stay on this one (a scratch library to try things in). The sync server now keeps several libraries per user under /api/libraries; a migration makes each existing library the user's default "Main", which /api/library keeps serving for older app builds. Every sync lines the device's libraries up with the account's: new ones elsewhere are downloaded, new ones here created on the server, ones deleted elsewhere flagged rather than thrown away. The existing library keeps its files and merge base, so upgrading asks nothing.
305 lines
9.5 KiB
Rust
305 lines
9.5 KiB
Rust
//! 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<String>,
|
|
/// 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<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,
|
|
}
|
|
|
|
/// 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<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(())
|
|
}
|
|
|
|
/// The account's libraries, the default first.
|
|
pub fn list_libraries(&self) -> Result<Vec<RemoteLibrary>> {
|
|
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<RemoteLibrary> {
|
|
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<RemoteLibrary> {
|
|
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"
|
|
);
|
|
}
|
|
}
|