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.
This commit is contained in:
2026-10-08 22:32:08 -05:00
parent df2cbb372a
commit de55fc634a
41 changed files with 6365 additions and 1662 deletions

View File

@@ -0,0 +1,37 @@
[package]
name = "mtgc-server"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Sync server for the mtgc library catalog: one versioned library per user, OIDC and/or local sign-in."
[lib]
name = "mtgc_server"
path = "src/lib.rs"
[[bin]]
name = "mtgc-server"
path = "src/main.rs"
[dependencies]
mtgc-core = { path = "../mtgc-core" }
rusqlite = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
axum = { version = "0.8", features = ["form", "json", "query", "tokio", "http1"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal", "sync"] }
openidconnect = { version = "4", default-features = false, features = ["reqwest", "rustls-tls"] }
argon2 = "0.5"
sha2 = "0.10"
base64 = "0.22"
getrandom = "0.3"
url = "2"
rpassword = "7"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"
ureq = { workspace = true }

View File

@@ -0,0 +1,24 @@
# Build from the repository root, so the workspace (and its lockfile) is in
# the context:
#
# docker build -f crates/mtgc-server/Dockerfile -t mtgc-server .
FROM rust:1-bookworm AS build
WORKDIR /src
COPY . .
RUN cargo build --release --locked -p mtgc-server \
&& cp target/release/mtgc-server /mtgc-server
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --uid 10001 --home /data mtgc \
&& mkdir /data && chown mtgc /data
COPY --from=build /mtgc-server /usr/local/bin/mtgc-server
USER mtgc
VOLUME /data
ENV MTGC_DB=/data/mtgc-server.db \
MTGC_BIND=0.0.0.0:8080
EXPOSE 8080
CMD ["mtgc-server"]

View File

@@ -0,0 +1,16 @@
# Where users reach the server, exactly as the reverse proxy serves it.
MTGC_PUBLIC_URL=https://mtgc.example.com
# Username/password accounts, managed with `mtgc-server user ...`.
# Set to false to allow single sign-on only.
MTGC_LOCAL_AUTH=true
# Single sign-on (optional). Register a confidential OIDC client with the
# redirect URI ${MTGC_PUBLIC_URL}/auth/callback.
#MTGC_OIDC_ISSUER=https://auth.example.com/application/o/mtgc/
#MTGC_OIDC_CLIENT_ID=
#MTGC_OIDC_CLIENT_SECRET=
#MTGC_OIDC_NAME=Authentik
#MTGC_OIDC_SCOPES=openid email profile
# Comma-separated; leave unset to let the provider decide who may sign in.
#MTGC_ALLOWED_EMAILS=

View File

@@ -0,0 +1,5 @@
# Replace with the hostname in MTGC_PUBLIC_URL. Caddy obtains the certificate.
mtgc.example.com {
encode gzip
reverse_proxy mtgc-server:8080
}

View File

@@ -0,0 +1,29 @@
# Example deployment: mtgc-server behind Caddy, which provides HTTPS.
#
# cp .env.example .env # then fill it in
# docker compose up -d
# docker compose exec mtgc-server mtgc-server user add yourname
services:
mtgc-server:
build:
context: ../../..
dockerfile: crates/mtgc-server/Dockerfile
restart: unless-stopped
env_file: .env
volumes:
- mtgc-data:/data
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
volumes:
mtgc-data:
caddy-data:

View File

@@ -0,0 +1,186 @@
//! Username/password accounts, for servers without an identity provider.
//!
//! Accounts are created by whoever runs the server (`mtgc-server user add`);
//! there is no sign-up page, so a server exposed to the internet can't be
//! filled with accounts by strangers.
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use argon2::password_hash::rand_core::OsRng;
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use argon2::Argon2;
use axum::extract::{Form, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::Response;
use serde::Deserialize;
use crate::{auth, db, pages, AppState};
/// Failures allowed before each further attempt has to wait.
const FREE_FAILURES: u32 = 5;
const MAX_DELAY: Duration = Duration::from_secs(15 * 60);
pub struct LocalAuth {
/// A real hash of nothing, verified against when the username doesn't
/// exist, so a wrong username takes as long to reject as a wrong password
/// and response times don't reveal which accounts exist.
dummy_hash: String,
failures: Mutex<HashMap<String, Failures>>,
}
struct Failures {
count: u32,
locked_until: Instant,
}
impl LocalAuth {
pub fn new() -> Self {
Self {
dummy_hash: hash_password("not a real password").expect("hashing works"),
failures: Mutex::new(HashMap::new()),
}
}
/// How long until this username may try again, if it must wait.
fn wait(&self, username: &str) -> Option<Duration> {
let map = self.failures.lock().unwrap_or_else(|e| e.into_inner());
let f = map.get(username)?;
f.locked_until.checked_duration_since(Instant::now())
}
fn record_failure(&self, username: &str) {
let mut map = self.failures.lock().unwrap_or_else(|e| e.into_inner());
let f = map
.entry(username.to_string())
.or_insert(Failures { count: 0, locked_until: Instant::now() });
f.count += 1;
if f.count >= FREE_FAILURES {
// 1s, 2s, 4s, ... capped. Slows guessing to a crawl without
// locking the real owner out for long.
let exp = (f.count - FREE_FAILURES).min(20);
let delay = Duration::from_secs(1u64 << exp).min(MAX_DELAY);
f.locked_until = Instant::now() + delay;
}
}
fn record_success(&self, username: &str) {
self.failures.lock().unwrap_or_else(|e| e.into_inner()).remove(username);
}
}
impl Default for LocalAuth {
fn default() -> Self {
Self::new()
}
}
/// Argon2id with the crate's defaults (19 MiB, 2 passes): OWASP's baseline.
pub fn hash_password(password: &str) -> Result<String, String> {
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| e.to_string())
}
fn verify_password(password: &str, hash: &str) -> bool {
PasswordHash::new(hash)
.map(|h| Argon2::default().verify_password(password.as_bytes(), &h).is_ok())
.unwrap_or(false)
}
/// Usernames are compared case-insensitively, as people expect them to be.
pub fn normalize_username(name: &str) -> String {
name.trim().to_lowercase()
}
#[derive(Deserialize)]
pub struct LoginForm {
pending: String,
username: String,
password: String,
}
/// The sign-in form's target.
pub async fn submit(
State(state): State<AppState>,
headers: HeaderMap,
Form(form): Form<LoginForm>,
) -> Response {
if !state.config().local_auth {
return pages::error(StatusCode::NOT_FOUND, "Password sign-in is turned off on this server.");
}
// The form only exists inside a sign-in the app started, in this browser;
// without one there is nowhere to send the result.
let pending = match auth::bound_pending(&state, &headers, &form.pending) {
Ok(p) => p,
Err(r) => return r,
};
let oidc_name = state.config().oidc.as_ref().map(|o| o.display_name.clone());
let username = normalize_username(&form.username);
if let Some(wait) = state.0.local.wait(&username) {
let msg = format!("Too many attempts. Try again in {} seconds.", wait.as_secs().max(1));
return pages::login(&pending, oidc_name.as_deref(), Some(&msg), &form.username);
}
let account = match db::local_user(&state.db(), &username) {
Ok(a) => a,
Err(e) => {
tracing::error!("load local user: {e}");
return pages::error(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong — try again.");
}
};
// Hashing is deliberately slow; keep it off the async workers.
let hash = account
.as_ref()
.map(|(_, h)| h.clone())
.unwrap_or_else(|| state.0.local.dummy_hash.clone());
let password = form.password.clone();
let ok = tokio::task::spawn_blocking(move || verify_password(&password, &hash))
.await
.unwrap_or(false);
match account {
Some((user, _)) if ok => {
state.0.local.record_success(&username);
auth::finish(&state, &pending, &user)
}
_ => {
state.0.local.record_failure(&username);
tracing::warn!(username = %username, "failed password sign-in");
pages::login(&pending, oidc_name.as_deref(), Some("Invalid username or password."), &form.username)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_password_verifies_against_its_own_hash_only() {
let h = hash_password("correct horse").unwrap();
assert!(h.starts_with("$argon2id$"));
assert!(verify_password("correct horse", &h));
assert!(!verify_password("wrong", &h));
assert!(!verify_password("correct horse", "not a hash"));
}
#[test]
fn repeated_failures_make_a_username_wait() {
let a = LocalAuth::new();
for _ in 0..FREE_FAILURES - 1 {
a.record_failure("me");
}
assert!(a.wait("me").is_none(), "a few typos are free");
a.record_failure("me");
assert!(a.wait("me").is_some());
assert!(a.wait("someone-else").is_none(), "only that username waits");
a.record_success("me");
assert!(a.wait("me").is_none());
}
}

View File

@@ -0,0 +1,304 @@
//! Signing a device in, and recognising it afterwards.
//!
//! Every sign-in follows the same shape, whichever way the user proves who
//! they are:
//!
//! 1. The app opens the browser at `/auth/start`, with where to send the user
//! back to (`redirect_uri`, an app-only address), a PKCE challenge, and its
//! own `state`. That becomes a *pending login*.
//! 2. The user signs in on this server's page ([`local`]) or at the identity
//! provider ([`oidc`]).
//! 3. Either way ends in [`finish`]: a single-use code is sent back to the app
//! at its `redirect_uri`.
//! 4. The app redeems the code with its PKCE verifier at `/auth/token` and gets
//! a token for that device alone.
//!
//! PKCE is what makes step 3 safe on a phone: another app that registered the
//! same URL scheme could receive the code, but can't redeem it without the
//! verifier, which never left the app that started the sign-in.
//!
//! Devices hold long-lived tokens rather than the provider's own, because those
//! expire within minutes and a device that has been offline for a week must
//! still be able to sync the moment it reconnects. Each is revocable on its own.
pub mod local;
pub mod oidc;
use axum::extract::{FromRequestParts, Path, Query, State};
use axum::http::header::{COOKIE, SET_COOKIE};
use axum::http::request::Parts;
use axum::http::{HeaderMap, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Redirect, Response};
use axum::Json;
use base64::Engine;
use serde::Deserialize;
use serde_json::json;
use sha2::{Digest, Sha256};
use crate::db::{self, PendingLogin, User};
use crate::{pages, random_token, sha256_hex, ApiError, AppState};
/// How long the user has to finish signing in once the browser opens.
const PENDING_TTL_SECS: i64 = 15 * 60;
/// How long the app has to redeem its code. It does so immediately.
const CODE_TTL_SECS: i64 = 120;
/// The only places a code may be sent: the desktop app's one-shot listener on
/// the loopback interface (any port, RFC 8252 §7.3), or the phone app's URL
/// scheme. Anything else is refused outright rather than redirected to, so the
/// server can never be used to deliver a code to a website.
pub fn redirect_allowed(uri: &str) -> bool {
if uri == "mtgc://auth/callback" {
return true;
}
let Ok(u) = url::Url::parse(uri) else {
return false;
};
u.scheme() == "http"
&& u.host_str() == Some("127.0.0.1")
&& u.port().is_some()
&& u.path() == "/callback"
&& u.query().is_none()
&& u.fragment().is_none()
&& u.username().is_empty()
&& u.password().is_none()
}
#[derive(Deserialize)]
pub struct StartParams {
redirect_uri: String,
code_challenge: String,
#[serde(default)]
code_challenge_method: Option<String>,
state: String,
#[serde(default)]
device_name: Option<String>,
}
/// Step 1: begin a sign-in and show the way to complete it.
pub async fn start(State(state): State<AppState>, Query(p): Query<StartParams>) -> Response {
if !redirect_allowed(&p.redirect_uri) {
return pages::error(StatusCode::BAD_REQUEST, "This sign-in link didn't come from the mtgc app.");
}
if p.code_challenge_method.as_deref().unwrap_or("S256") != "S256" || p.code_challenge.len() < 43 {
return pages::error(StatusCode::BAD_REQUEST, "This version of the app isn't supported — update it.");
}
let device_name = p
.device_name
.map(|n| n.trim().chars().take(80).collect::<String>())
.filter(|n| !n.is_empty())
.unwrap_or_else(|| "Unnamed device".into());
let pending = PendingLogin {
id: random_token(),
redirect_uri: p.redirect_uri,
app_state: p.state.chars().take(200).collect(),
code_challenge: p.code_challenge,
device_name,
oidc_nonce: None,
oidc_verifier: None,
};
if let Err(e) = db::create_pending(&state.db(), &pending, PENDING_TTL_SECS) {
tracing::error!("create pending login: {e}");
return pages::error(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong — try again.");
}
let c = state.config();
let mut resp = if c.local_auth {
pages::login(&pending, c.oidc.as_ref().map(|o| o.display_name.as_str()), None, "")
} else {
// Nothing to choose between: go straight to the provider.
Redirect::to(&format!("/auth/oidc?pending={}", pending.id)).into_response()
};
let secure = if c.public_url.starts_with("https://") { "; Secure" } else { "" };
let cookie = format!(
"{LOGIN_COOKIE}={}; Path=/auth; HttpOnly; SameSite=Lax; Max-Age={PENDING_TTL_SECS}{secure}",
pending.id
);
if let Ok(v) = HeaderValue::from_str(&cookie) {
resp.headers_mut().append(SET_COOKIE, v);
}
resp
}
/// Ties a sign-in to the browser it started in. The pending login's id already
/// travels in the form and in the provider's `state`, but requiring it in a
/// cookie as well means a link or form planted on another site can't carry
/// someone's browser through a sign-in it didn't start.
const LOGIN_COOKIE: &str = "mtgc_login";
/// The pending login `id`, provided this browser is the one that started it.
/// Fails with the page to show instead.
#[allow(clippy::result_large_err)] // once per sign-in step; boxing buys nothing
pub(crate) fn bound_pending(
state: &AppState,
headers: &HeaderMap,
id: &str,
) -> Result<PendingLogin, Response> {
let expired = || {
pages::error(StatusCode::BAD_REQUEST, "This sign-in has expired. Start again from the app.")
};
let from_cookie = headers
.get_all(COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|v| v.split(';'))
.filter_map(|c| c.trim().split_once('='))
.find(|(k, _)| *k == LOGIN_COOKIE)
.map(|(_, v)| v.to_string());
if id.is_empty() || from_cookie.as_deref() != Some(id) {
return Err(expired());
}
match db::pending(&state.db(), id) {
Ok(Some(p)) => Ok(p),
Ok(None) => Err(expired()),
Err(e) => {
tracing::error!("load pending login: {e}");
Err(pages::error(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong — try again."))
}
}
}
/// Step 3: the user is who they say they are. Mint the app's code and send the
/// browser back to it.
pub(crate) fn finish(state: &AppState, pending: &PendingLogin, user: &User) -> Response {
let code = random_token();
let stored = {
let conn = state.db();
db::create_auth_code(
&conn,
&sha256_hex(&code),
user.id,
&pending.code_challenge,
&pending.device_name,
CODE_TTL_SECS,
)
.and_then(|_| db::delete_pending(&conn, &pending.id))
};
if let Err(e) = stored {
tracing::error!("store auth code: {e}");
return pages::error(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong — try again.");
}
tracing::info!(user = %user.sub, device = %pending.device_name, "signed in");
pages::return_to_app(&back_to_app(pending, &[("code", &code)]))
}
/// The app's redirect URI with `params` and its own `state` attached.
pub(crate) fn back_to_app(pending: &PendingLogin, params: &[(&str, &str)]) -> String {
let mut u = url::Url::parse(&pending.redirect_uri).expect("validated in start()");
{
let mut q = u.query_pairs_mut();
for (k, v) in params {
q.append_pair(k, v);
}
q.append_pair("state", &pending.app_state);
}
u.into()
}
#[derive(Deserialize)]
pub struct TokenRequest {
code: String,
code_verifier: String,
}
/// Step 4: trade the code and PKCE verifier for this device's token.
pub async fn token(
State(state): State<AppState>,
Json(req): Json<TokenRequest>,
) -> Result<Response, ApiError> {
let invalid = || ApiError::new(StatusCode::BAD_REQUEST, "sign-in code is invalid or expired");
let conn = state.db();
let (user_id, challenge, device_name) =
db::take_auth_code(&conn, &sha256_hex(&req.code))?.ok_or_else(invalid)?;
if pkce_challenge(&req.code_verifier) != challenge {
return Err(invalid());
}
let user = db::user_by_id(&conn, user_id)?.ok_or_else(invalid)?;
let token = format!("mtgc_{}", random_token());
let device_id = db::create_device(&conn, user.id, &sha256_hex(&token), &device_name)?;
Ok(Json(json!({
"token": token,
"device_id": device_id,
"user": user_json(&user),
}))
.into_response())
}
/// S256 per RFC 7636: base64url(SHA-256(verifier)), unpadded.
pub fn pkce_challenge(verifier: &str) -> String {
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()))
}
fn user_json(u: &User) -> serde_json::Value {
json!({
"id": u.public_id,
"username": u.sub,
"display_name": u.display_name,
"email": u.email,
"method": if u.iss == db::LOCAL_ISS { "local" } else { "oidc" },
})
}
/// A request from a signed-in device.
pub struct Authed {
pub user: User,
pub device_id: i64,
}
impl FromRequestParts<AppState> for Authed {
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, ApiError> {
let token = parts
.headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(str::trim)
.ok_or_else(ApiError::unauthorized)?;
let conn = state.db();
let device = db::device_by_token(&conn, &sha256_hex(token))?.ok_or_else(ApiError::unauthorized)?;
let user = db::user_by_id(&conn, device.user_id)?.ok_or_else(ApiError::unauthorized)?;
Ok(Self { user, device_id: device.id })
}
}
/// The signed-in account and its devices.
pub async fn me(State(state): State<AppState>, auth: Authed) -> Result<Response, ApiError> {
let devices = db::devices_for(&state.db(), auth.user.id)?;
let devices: Vec<_> = devices
.iter()
.map(|d| {
json!({
"id": d.id,
"name": d.name,
"created_at": d.created_at,
"last_seen_at": d.last_seen_at,
"current": d.id == auth.device_id,
})
})
.collect();
Ok(Json(json!({ "user": user_json(&auth.user), "devices": devices })).into_response())
}
/// Sign this device out.
pub async fn logout(State(state): State<AppState>, auth: Authed) -> Result<StatusCode, ApiError> {
db::delete_device(&state.db(), auth.user.id, auth.device_id)?;
Ok(StatusCode::NO_CONTENT)
}
/// Sign out another of the user's devices — a lost phone, say.
pub async fn revoke_device(
State(state): State<AppState>,
auth: Authed,
Path(id): Path<i64>,
) -> Result<StatusCode, ApiError> {
if db::delete_device(&state.db(), auth.user.id, id)? {
Ok(StatusCode::NO_CONTENT)
} else {
Err(ApiError::new(StatusCode::NOT_FOUND, "no such device"))
}
}

View File

@@ -0,0 +1,266 @@
//! Signing in through the server owner's OpenID Connect provider.
//!
//! The server is a confidential client: it holds the client secret, makes the
//! code exchange itself, and checks the ID token (signature, issuer, audience,
//! nonce). The provider's tokens are used once, to learn who the user is, and
//! then discarded — devices get their own tokens (see [`super`]).
use std::time::{Duration, Instant};
use axum::extract::{Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Redirect, Response};
use openidconnect::core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata};
use openidconnect::{
AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce, PkceCodeChallenge,
PkceCodeVerifier, RedirectUrl, Scope, TokenResponse,
};
use serde::Deserialize;
use crate::config::OidcConfig;
use crate::{auth, db, pages, AppState};
/// How long discovered provider metadata (including its signing keys) is
/// trusted before being fetched again, so a key rotation at the provider is
/// picked up without restarting the server.
const METADATA_TTL: Duration = Duration::from_secs(60 * 60);
pub struct Oidc {
config: OidcConfig,
http: openidconnect::reqwest::Client,
metadata: tokio::sync::Mutex<Option<(Instant, CoreProviderMetadata)>>,
}
impl Oidc {
pub fn new(config: OidcConfig) -> anyhow::Result<Self> {
IssuerUrl::new(config.issuer.clone())?;
let http = openidconnect::reqwest::ClientBuilder::new()
// Following redirects from the token endpoint would let a
// misbehaving provider bounce the client secret elsewhere.
.redirect(openidconnect::reqwest::redirect::Policy::none())
.timeout(Duration::from_secs(20))
.build()?;
Ok(Self { config, http, metadata: tokio::sync::Mutex::new(None) })
}
/// The provider's metadata, discovered lazily so the server starts (and
/// password sign-in keeps working) even while the provider is down.
async fn metadata(&self, force: bool) -> anyhow::Result<CoreProviderMetadata> {
let mut cached = self.metadata.lock().await;
if let Some((at, meta)) = cached.as_ref() {
if !force && at.elapsed() < METADATA_TTL {
return Ok(meta.clone());
}
}
let meta =
CoreProviderMetadata::discover_async(IssuerUrl::new(self.config.issuer.clone())?, &self.http)
.await?;
*cached = Some((Instant::now(), meta.clone()));
Ok(meta)
}
fn client(
&self,
meta: CoreProviderMetadata,
redirect_uri: String,
) -> anyhow::Result<
CoreClient<
openidconnect::EndpointSet,
openidconnect::EndpointNotSet,
openidconnect::EndpointNotSet,
openidconnect::EndpointNotSet,
openidconnect::EndpointMaybeSet,
openidconnect::EndpointMaybeSet,
>,
> {
Ok(CoreClient::from_provider_metadata(
meta,
ClientId::new(self.config.client_id.clone()),
Some(ClientSecret::new(self.config.client_secret.clone())),
)
.set_redirect_uri(RedirectUrl::new(redirect_uri)?))
}
}
#[derive(Deserialize)]
pub struct BeginParams {
pending: String,
}
/// Send the browser to the provider for a sign-in the app started.
pub async fn begin(
State(state): State<AppState>,
headers: HeaderMap,
Query(p): Query<BeginParams>,
) -> Response {
let Some(oidc) = state.0.oidc.as_ref() else {
return pages::error(StatusCode::NOT_FOUND, "Single sign-on isn't set up on this server.");
};
let pending = match auth::bound_pending(&state, &headers, &p.pending) {
Ok(p) => p,
Err(r) => return r,
};
let meta = match oidc.metadata(false).await {
Ok(m) => m,
Err(e) => {
tracing::error!("OIDC discovery failed: {e:#}");
return pages::error(
StatusCode::BAD_GATEWAY,
"The sign-in provider isn't reachable right now. Try again shortly.",
);
}
};
let client = match oidc.client(meta, state.config().oidc_redirect_uri()) {
Ok(c) => c,
Err(e) => {
tracing::error!("OIDC client: {e:#}");
return pages::error(StatusCode::INTERNAL_SERVER_ERROR, "Single sign-on is misconfigured.");
}
};
let (challenge, verifier) = PkceCodeChallenge::new_random_sha256();
let pending_id = pending.id.clone();
let mut req = client
.authorize_url(
CoreAuthenticationFlow::AuthorizationCode,
// The pending login's id is the state: it is what ties the
// provider's answer back to this sign-in.
move || CsrfToken::new(pending_id),
Nonce::new_random,
)
.set_pkce_challenge(challenge);
for scope in &oidc.config.scopes {
req = req.add_scope(Scope::new(scope.clone()));
}
let (url, _state, nonce) = req.url();
if let Err(e) = db::set_pending_oidc(&state.db(), &pending.id, nonce.secret(), verifier.secret()) {
tracing::error!("store OIDC nonce: {e}");
return pages::error(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong — try again.");
}
Redirect::to(url.as_str()).into_response()
}
#[derive(Deserialize)]
pub struct CallbackParams {
state: Option<String>,
code: Option<String>,
error: Option<String>,
}
/// The provider's answer.
pub async fn callback(
State(state): State<AppState>,
headers: HeaderMap,
Query(p): Query<CallbackParams>,
) -> Response {
let Some(oidc) = state.0.oidc.as_ref() else {
return pages::error(StatusCode::NOT_FOUND, "Single sign-on isn't set up on this server.");
};
let pending = match auth::bound_pending(&state, &headers, p.state.as_deref().unwrap_or_default()) {
Ok(p) => p,
Err(r) => return r,
};
if let Some(err) = p.error {
// Declined at the provider, or the provider refused. Tell the app, so
// it stops waiting, rather than leaving the user on a dead end here.
tracing::info!("OIDC sign-in ended with {err}");
let _ = db::delete_pending(&state.db(), &pending.id);
return pages::return_to_app(&auth::back_to_app(&pending, &[("error", "access_denied")]));
}
let (Some(code), Some(nonce), Some(verifier)) =
(p.code, pending.oidc_nonce.clone(), pending.oidc_verifier.clone())
else {
return pages::error(StatusCode::BAD_REQUEST, "This sign-in has expired. Start again from the app.");
};
let identity = match identify(&state, oidc, code, nonce, verifier).await {
Ok(i) => i,
Err(e) => {
tracing::warn!("OIDC sign-in failed: {e:#}");
return pages::error(StatusCode::BAD_GATEWAY, "Sign-in couldn't be completed. Start again from the app.");
}
};
let allowed = &oidc.config.allowed_emails;
if !allowed.is_empty() {
let email = identity.email.to_lowercase();
if !identity.email_verified || !allowed.contains(&email) {
tracing::warn!(sub = %identity.sub, email = %email, "OIDC sign-in not on the allow list");
return pages::error(StatusCode::FORBIDDEN, "This account isn't allowed to use this server.");
}
}
let user = match db::upsert_user(
&state.db(),
&identity.iss,
&identity.sub,
&identity.display_name,
&identity.email,
) {
Ok(u) => u,
Err(e) => {
tracing::error!("upsert OIDC user: {e}");
return pages::error(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong — try again.");
}
};
auth::finish(&state, &pending, &user)
}
struct Identity {
iss: String,
sub: String,
display_name: String,
email: String,
email_verified: bool,
}
async fn identify(
state: &AppState,
oidc: &Oidc,
code: String,
nonce: String,
verifier: String,
) -> anyhow::Result<Identity> {
let meta = oidc.metadata(false).await?;
let client = oidc.client(meta, state.config().oidc_redirect_uri())?;
let response = client
.exchange_code(AuthorizationCode::new(code))?
.set_pkce_verifier(PkceCodeVerifier::new(verifier))
.request_async(&oidc.http)
.await?;
let id_token = response
.id_token()
.ok_or_else(|| anyhow::anyhow!("the provider returned no ID token"))?;
let nonce = Nonce::new(nonce);
let claims = match id_token.claims(&client.id_token_verifier(), &nonce) {
Ok(c) => c.clone(),
Err(first) => {
// Perhaps the provider rotated its signing keys since we last
// looked. Refetch once before giving up.
tracing::info!("ID token didn't verify ({first}); refreshing provider keys");
let meta = oidc.metadata(true).await?;
let client = oidc.client(meta, state.config().oidc_redirect_uri())?;
let claims = id_token.claims(&client.id_token_verifier(), &nonce)?.clone();
claims
}
};
let email = claims.email().map(|e| e.as_str().to_string()).unwrap_or_default();
let display_name = claims
.name()
.and_then(|n| n.get(None))
.map(|n| n.as_str().to_string())
.or_else(|| claims.preferred_username().map(|u| u.as_str().to_string()))
.unwrap_or_else(|| email.clone());
Ok(Identity {
iss: claims.issuer().as_str().to_string(),
sub: claims.subject().as_str().to_string(),
display_name,
email_verified: claims.email_verified().unwrap_or(false),
email,
})
}

View File

@@ -0,0 +1,172 @@
//! Server configuration, read from the environment.
//!
//! Environment variables rather than a file because the server is meant to run
//! in a container behind a reverse proxy, where that is the native way to
//! configure things — and there are few enough settings that a file would be
//! all ceremony.
use std::net::SocketAddr;
use std::path::PathBuf;
use anyhow::{bail, Context};
#[derive(Debug, Clone)]
pub struct Config {
/// The URL users reach the server at, as the reverse proxy serves it. Used
/// to build the OIDC redirect URI, so it must match what the provider has
/// registered exactly.
pub public_url: String,
pub bind: SocketAddr,
pub db_path: PathBuf,
/// Username/password accounts, managed with `mtgc-server user ...`.
pub local_auth: bool,
pub oidc: Option<OidcConfig>,
}
#[derive(Debug, Clone)]
pub struct OidcConfig {
pub issuer: String,
pub client_id: String,
pub client_secret: String,
pub scopes: Vec<String>,
/// Shown on the sign-in button: "Sign in with <name>".
pub display_name: String,
/// When non-empty, only these (verified) addresses may sign in. Empty means
/// the provider alone decides who gets in, which is usually right: limiting
/// access to the application is a provider-side setting in Authentik and
/// Keycloak alike.
pub allowed_emails: Vec<String>,
}
impl Config {
pub fn from_env() -> anyhow::Result<Self> {
Self::from_lookup(|k| std::env::var(k).ok())
}
/// Read settings through `get`, so tests can supply them without touching
/// the process environment.
pub fn from_lookup(get: impl Fn(&str) -> Option<String>) -> anyhow::Result<Self> {
let get = |k: &str| get(k).map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
let public_url = get("MTGC_PUBLIC_URL")
.context("MTGC_PUBLIC_URL is required — the https:// address the server is reached at")?
.trim_end_matches('/')
.to_string();
url::Url::parse(&public_url).context("MTGC_PUBLIC_URL isn't a valid URL")?;
let bind = get("MTGC_BIND")
.unwrap_or_else(|| "0.0.0.0:8080".into())
.parse()
.context("MTGC_BIND must look like 0.0.0.0:8080")?;
let db_path = PathBuf::from(get("MTGC_DB").unwrap_or_else(|| "mtgc-server.db".into()));
let local_auth = match get("MTGC_LOCAL_AUTH").as_deref() {
None => true,
Some(v) => parse_bool(v).context("MTGC_LOCAL_AUTH must be true or false")?,
};
let oidc = match get("MTGC_OIDC_ISSUER") {
None => None,
Some(issuer) => Some(OidcConfig {
issuer,
client_id: get("MTGC_OIDC_CLIENT_ID")
.context("MTGC_OIDC_CLIENT_ID is required when MTGC_OIDC_ISSUER is set")?,
client_secret: get("MTGC_OIDC_CLIENT_SECRET")
.context("MTGC_OIDC_CLIENT_SECRET is required when MTGC_OIDC_ISSUER is set")?,
scopes: get("MTGC_OIDC_SCOPES")
.unwrap_or_else(|| "openid email profile".into())
.split_whitespace()
.filter(|s| *s != "openid") // always requested
.map(str::to_string)
.collect(),
display_name: get("MTGC_OIDC_NAME").unwrap_or_else(|| "single sign-on".into()),
allowed_emails: get("MTGC_ALLOWED_EMAILS")
.map(|v| {
v.split(',')
.map(|e| e.trim().to_lowercase())
.filter(|e| !e.is_empty())
.collect()
})
.unwrap_or_default(),
}),
};
if !local_auth && oidc.is_none() {
bail!(
"no way to sign in: set MTGC_OIDC_ISSUER (and its client settings), \
or leave MTGC_LOCAL_AUTH enabled"
);
}
Ok(Self { public_url, bind, db_path, local_auth, oidc })
}
pub fn oidc_redirect_uri(&self) -> String {
format!("{}/auth/callback", self.public_url)
}
}
fn parse_bool(v: &str) -> Option<bool> {
match v.to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
_ => None,
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
fn cfg(vars: &[(&str, &str)]) -> anyhow::Result<Config> {
let map: HashMap<String, String> =
vars.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
Config::from_lookup(|k| map.get(k).cloned())
}
#[test]
fn local_accounts_alone_need_only_the_public_url() {
let c = cfg(&[("MTGC_PUBLIC_URL", "https://mtgc.example/")]).unwrap();
assert!(c.local_auth);
assert!(c.oidc.is_none());
assert_eq!(c.oidc_redirect_uri(), "https://mtgc.example/auth/callback");
}
#[test]
fn disabling_every_sign_in_method_is_refused() {
let err = cfg(&[("MTGC_PUBLIC_URL", "https://m.example"), ("MTGC_LOCAL_AUTH", "false")])
.unwrap_err();
assert!(err.to_string().contains("no way to sign in"));
}
#[test]
fn oidc_needs_its_client_settings() {
let err = cfg(&[
("MTGC_PUBLIC_URL", "https://m.example"),
("MTGC_OIDC_ISSUER", "https://id.example/realms/home"),
])
.unwrap_err();
assert!(err.to_string().contains("MTGC_OIDC_CLIENT_ID"));
}
#[test]
fn oidc_settings_are_read() {
let c = cfg(&[
("MTGC_PUBLIC_URL", "https://m.example"),
("MTGC_LOCAL_AUTH", "off"),
("MTGC_OIDC_ISSUER", "https://id.example/realms/home"),
("MTGC_OIDC_CLIENT_ID", "mtgc"),
("MTGC_OIDC_CLIENT_SECRET", "s3cret"),
("MTGC_OIDC_NAME", "Authentik"),
("MTGC_ALLOWED_EMAILS", "Me@Example.com, you@example.com"),
])
.unwrap();
let o = c.oidc.unwrap();
assert!(!c.local_auth);
assert_eq!(o.scopes, vec!["email", "profile"]);
assert_eq!(o.allowed_emails, vec!["me@example.com", "you@example.com"]);
assert_eq!(o.display_name, "Authentik");
}
}

View File

@@ -0,0 +1,450 @@
//! The server's SQLite database: accounts, devices, sign-ins in progress, and
//! each user's library.
//!
//! Everything here is synchronous and runs under the one connection mutex in
//! [`crate::AppState`]. Every statement is a quick indexed lookup or a
//! single-row write, so serialising them costs nothing at the scale of a
//! personal server and rules out a whole class of races — most importantly in
//! [`put_library`], whose compare-and-swap is only sound because nothing else
//! can write between its check and its update.
use std::path::Path;
use rusqlite::{params, Connection, OptionalExtension};
use crate::now;
const MIGRATIONS: &[&str] = &[
// 1: initial schema
"CREATE TABLE users (
id INTEGER PRIMARY KEY,
-- What clients see as the account id. Random rather than the rowid so
-- a rebuilt database can't hand a device's old id to someone else.
public_id TEXT NOT NULL UNIQUE,
-- 'local' for username/password accounts (sub = username); the OIDC
-- issuer URL otherwise.
iss TEXT NOT NULL,
sub TEXT NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
password_hash TEXT,
created_at INTEGER NOT NULL,
UNIQUE (iss, sub)
);
CREATE TABLE devices (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL
);
CREATE TABLE pending_logins (
id TEXT PRIMARY KEY,
redirect_uri TEXT NOT NULL,
app_state TEXT NOT NULL,
code_challenge TEXT NOT NULL,
device_name TEXT NOT NULL,
oidc_nonce TEXT,
oidc_verifier TEXT,
expires_at INTEGER NOT NULL
);
CREATE TABLE auth_codes (
code_hash TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code_challenge TEXT NOT NULL,
device_name TEXT NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE libraries (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
version INTEGER NOT NULL,
doc TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE library_history (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
version INTEGER NOT NULL,
doc TEXT NOT NULL,
saved_at INTEGER NOT NULL,
PRIMARY KEY (user_id, version)
);",
];
/// How many superseded versions of each library are kept. A safety net for
/// recovering from a bad merge or a misbehaving client by hand, not a feature
/// the app exposes; a few dozen covers days of normal use.
pub const HISTORY_KEEP: i64 = 50;
pub fn open(path: &Path) -> rusqlite::Result<Connection> {
let conn = Connection::open(path)?;
conn.pragma_update(None, "journal_mode", "WAL")?;
init(conn)
}
pub fn open_in_memory() -> rusqlite::Result<Connection> {
init(Connection::open_in_memory()?)
}
fn init(conn: Connection) -> rusqlite::Result<Connection> {
conn.pragma_update(None, "foreign_keys", "ON")?;
conn.busy_timeout(std::time::Duration::from_secs(5))?;
let current: usize = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
for (i, sql) in MIGRATIONS.iter().enumerate().skip(current) {
let tx = conn.unchecked_transaction()?;
tx.execute_batch(sql)?;
tx.pragma_update(None, "user_version", i + 1)?;
tx.commit()?;
}
Ok(conn)
}
// --- users ------------------------------------------------------------------
#[derive(Debug, Clone)]
pub struct User {
pub id: i64,
pub public_id: String,
pub iss: String,
pub sub: String,
pub display_name: String,
pub email: String,
}
const USER_COLS: &str = "id, public_id, iss, sub, display_name, email";
fn user_row(r: &rusqlite::Row) -> rusqlite::Result<User> {
Ok(User {
id: r.get(0)?,
public_id: r.get(1)?,
iss: r.get(2)?,
sub: r.get(3)?,
display_name: r.get(4)?,
email: r.get(5)?,
})
}
pub const LOCAL_ISS: &str = "local";
/// Create the user for an identity, or refresh the profile details of the one
/// that already exists. The identity itself (`iss`, `sub`) never changes.
pub fn upsert_user(
conn: &Connection,
iss: &str,
sub: &str,
display_name: &str,
email: &str,
) -> rusqlite::Result<User> {
conn.execute(
"INSERT INTO users (public_id, iss, sub, display_name, email, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT (iss, sub) DO UPDATE SET
display_name = excluded.display_name,
email = excluded.email",
params![crate::random_hex(16), iss, sub, display_name, email, now()],
)?;
conn.query_row(
&format!("SELECT {USER_COLS} FROM users WHERE iss = ?1 AND sub = ?2"),
params![iss, sub],
user_row,
)
}
pub fn user_by_id(conn: &Connection, id: i64) -> rusqlite::Result<Option<User>> {
conn.query_row(&format!("SELECT {USER_COLS} FROM users WHERE id = ?1"), [id], user_row)
.optional()
}
/// A local account and its password hash.
pub fn local_user(conn: &Connection, username: &str) -> rusqlite::Result<Option<(User, String)>> {
conn.query_row(
&format!(
"SELECT {USER_COLS}, password_hash FROM users
WHERE iss = '{LOCAL_ISS}' AND sub = ?1 AND password_hash IS NOT NULL"
),
[username],
|r| Ok((user_row(r)?, r.get(6)?)),
)
.optional()
}
/// Create a local account. Fails if the username is taken.
pub fn create_local_user(
conn: &Connection,
username: &str,
display_name: &str,
password_hash: &str,
) -> rusqlite::Result<User> {
conn.execute(
"INSERT INTO users (public_id, iss, sub, display_name, password_hash, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![crate::random_hex(16), LOCAL_ISS, username, display_name, password_hash, now()],
)?;
Ok(local_user(conn, username)?.expect("just inserted").0)
}
/// Returns whether the account exists.
pub fn set_local_password(
conn: &Connection,
username: &str,
password_hash: &str,
) -> rusqlite::Result<bool> {
Ok(conn.execute(
"UPDATE users SET password_hash = ?2 WHERE iss = 'local' AND sub = ?1",
params![username, password_hash],
)? > 0)
}
/// Delete a local account along with its devices and library. Returns whether
/// it existed.
pub fn delete_local_user(conn: &Connection, username: &str) -> rusqlite::Result<bool> {
Ok(conn.execute("DELETE FROM users WHERE iss = 'local' AND sub = ?1", [username])? > 0)
}
/// Every account, for `mtgc-server user list`: (user, device count).
pub fn list_users(conn: &Connection) -> rusqlite::Result<Vec<(User, i64)>> {
let mut stmt = conn.prepare(&format!(
"SELECT {USER_COLS}, (SELECT COUNT(*) FROM devices d WHERE d.user_id = users.id)
FROM users ORDER BY iss, sub"
))?;
let rows = stmt.query_map([], |r| Ok((user_row(r)?, r.get(6)?)))?;
rows.collect()
}
// --- devices ----------------------------------------------------------------
#[derive(Debug, Clone)]
pub struct Device {
pub id: i64,
pub user_id: i64,
pub name: String,
pub created_at: i64,
pub last_seen_at: i64,
}
pub fn create_device(
conn: &Connection,
user_id: i64,
token_hash: &str,
name: &str,
) -> rusqlite::Result<i64> {
let t = now();
conn.execute(
"INSERT INTO devices (user_id, token_hash, name, created_at, last_seen_at)
VALUES (?1, ?2, ?3, ?4, ?4)",
params![user_id, token_hash, name, t],
)?;
Ok(conn.last_insert_rowid())
}
/// Look up the device a token belongs to, noting that it was just used.
pub fn device_by_token(conn: &Connection, token_hash: &str) -> rusqlite::Result<Option<Device>> {
let device = conn
.query_row(
"SELECT id, user_id, name, created_at, last_seen_at FROM devices WHERE token_hash = ?1",
[token_hash],
|r| {
Ok(Device {
id: r.get(0)?,
user_id: r.get(1)?,
name: r.get(2)?,
created_at: r.get(3)?,
last_seen_at: r.get(4)?,
})
},
)
.optional()?;
if let Some(d) = &device {
conn.execute("UPDATE devices SET last_seen_at = ?2 WHERE id = ?1", params![d.id, now()])?;
}
Ok(device)
}
pub fn devices_for(conn: &Connection, user_id: i64) -> rusqlite::Result<Vec<Device>> {
let mut stmt = conn.prepare(
"SELECT id, user_id, name, created_at, last_seen_at FROM devices
WHERE user_id = ?1 ORDER BY last_seen_at DESC",
)?;
let rows = stmt.query_map([user_id], |r| {
Ok(Device {
id: r.get(0)?,
user_id: r.get(1)?,
name: r.get(2)?,
created_at: r.get(3)?,
last_seen_at: r.get(4)?,
})
})?;
rows.collect()
}
/// Revoke one of a user's devices. Scoped to the user so nobody can revoke
/// someone else's. Returns whether it existed.
pub fn delete_device(conn: &Connection, user_id: i64, device_id: i64) -> rusqlite::Result<bool> {
Ok(conn.execute(
"DELETE FROM devices WHERE id = ?1 AND user_id = ?2",
params![device_id, user_id],
)? > 0)
}
// --- sign-in in progress ----------------------------------------------------
#[derive(Debug, Clone)]
pub struct PendingLogin {
pub id: String,
pub redirect_uri: String,
pub app_state: String,
pub code_challenge: String,
pub device_name: String,
pub oidc_nonce: Option<String>,
pub oidc_verifier: Option<String>,
}
pub fn create_pending(conn: &Connection, p: &PendingLogin, ttl_secs: i64) -> rusqlite::Result<()> {
// Sweep expired rows here: sign-ins are rare enough that this is the only
// housekeeping the table ever needs.
let t = now();
conn.execute("DELETE FROM pending_logins WHERE expires_at < ?1", [t])?;
conn.execute("DELETE FROM auth_codes WHERE expires_at < ?1", [t])?;
conn.execute(
"INSERT INTO pending_logins
(id, redirect_uri, app_state, code_challenge, device_name, expires_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![p.id, p.redirect_uri, p.app_state, p.code_challenge, p.device_name, t + ttl_secs],
)?;
Ok(())
}
pub fn pending(conn: &Connection, id: &str) -> rusqlite::Result<Option<PendingLogin>> {
conn.query_row(
"SELECT id, redirect_uri, app_state, code_challenge, device_name, oidc_nonce, oidc_verifier
FROM pending_logins WHERE id = ?1 AND expires_at >= ?2",
params![id, now()],
|r| {
Ok(PendingLogin {
id: r.get(0)?,
redirect_uri: r.get(1)?,
app_state: r.get(2)?,
code_challenge: r.get(3)?,
device_name: r.get(4)?,
oidc_nonce: r.get(5)?,
oidc_verifier: r.get(6)?,
})
},
)
.optional()
}
pub fn set_pending_oidc(
conn: &Connection,
id: &str,
nonce: &str,
verifier: &str,
) -> rusqlite::Result<()> {
conn.execute(
"UPDATE pending_logins SET oidc_nonce = ?2, oidc_verifier = ?3 WHERE id = ?1",
params![id, nonce, verifier],
)?;
Ok(())
}
pub fn delete_pending(conn: &Connection, id: &str) -> rusqlite::Result<()> {
conn.execute("DELETE FROM pending_logins WHERE id = ?1", [id])?;
Ok(())
}
pub fn create_auth_code(
conn: &Connection,
code_hash: &str,
user_id: i64,
code_challenge: &str,
device_name: &str,
ttl_secs: i64,
) -> rusqlite::Result<()> {
conn.execute(
"INSERT INTO auth_codes (code_hash, user_id, code_challenge, device_name, expires_at)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![code_hash, user_id, code_challenge, device_name, now() + ttl_secs],
)?;
Ok(())
}
/// Consume an app code: it is deleted whether or not it turns out to be
/// redeemable, so a code can be tried exactly once.
pub fn take_auth_code(
conn: &Connection,
code_hash: &str,
) -> rusqlite::Result<Option<(i64, String, String)>> {
let row = conn
.query_row(
"SELECT user_id, code_challenge, device_name FROM auth_codes
WHERE code_hash = ?1 AND expires_at >= ?2",
params![code_hash, now()],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.optional()?;
conn.execute("DELETE FROM auth_codes WHERE code_hash = ?1", [code_hash])?;
Ok(row)
}
// --- libraries --------------------------------------------------------------
/// A user's library: (version, document JSON). Version 0 with no document
/// means nothing has been stored yet.
pub fn get_library(conn: &Connection, user_id: i64) -> rusqlite::Result<Option<(i64, String)>> {
conn.query_row(
"SELECT version, doc FROM libraries WHERE user_id = ?1",
[user_id],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()
}
pub enum PutResult {
Stored(i64),
/// Someone else wrote first: the current version and document.
Conflict(i64, Option<String>),
}
/// Replace a user's library, but only if it is still at `if_match`.
pub fn put_library(
conn: &Connection,
user_id: i64,
if_match: i64,
doc: &str,
) -> rusqlite::Result<PutResult> {
let tx = conn.unchecked_transaction()?;
let current = get_library(&tx, user_id)?;
let current_version = current.as_ref().map(|(v, _)| *v).unwrap_or(0);
if current_version != if_match {
return Ok(PutResult::Conflict(current_version, current.map(|(_, d)| d)));
}
let t = now();
if let Some((v, old)) = &current {
tx.execute(
"INSERT OR REPLACE INTO library_history (user_id, version, doc, saved_at)
VALUES (?1, ?2, ?3, ?4)",
params![user_id, v, old, t],
)?;
tx.execute(
"DELETE FROM library_history WHERE user_id = ?1 AND version <= ?2",
params![user_id, v - HISTORY_KEEP],
)?;
}
let version = current_version + 1;
tx.execute(
"INSERT INTO libraries (user_id, version, doc, updated_at) VALUES (?1, ?2, ?3, ?4)
ON CONFLICT (user_id) DO UPDATE SET
version = excluded.version, doc = excluded.doc, updated_at = excluded.updated_at",
params![user_id, version, doc, t],
)?;
tx.commit()?;
Ok(PutResult::Stored(version))
}
pub fn history_versions(conn: &Connection, user_id: i64) -> rusqlite::Result<Vec<i64>> {
let mut stmt = conn
.prepare("SELECT version FROM library_history WHERE user_id = ?1 ORDER BY version")?;
let rows = stmt.query_map([user_id], |r| r.get(0))?;
rows.collect()
}

View File

@@ -0,0 +1,158 @@
//! mtgc-server — keeps each user's library so their devices can sync it.
//!
//! The server is deliberately dumb about libraries: it stores one versioned
//! document per user and accepts a write only when the writer names the
//! version it is replacing. All merging happens on the devices (see
//! `mtgc_core::sync::remote`), which is what lets them keep working offline and
//! keeps this side small enough to trust.
//!
//! Sign-in always happens in the browser, on pages this server serves, and
//! ends by handing the app a long-lived token for that one device. Passwords
//! therefore never pass through the app, and the app needs no knowledge of
//! which sign-in methods a server offers.
pub mod auth;
pub mod config;
pub mod db;
mod library;
mod pages;
use std::sync::{Arc, Mutex, MutexGuard};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::{delete, get, post};
use axum::{Json, Router};
use base64::Engine;
use rusqlite::Connection;
use sha2::{Digest, Sha256};
pub use config::Config;
/// Largest library document accepted. A large collection serialises to a few
/// hundred kilobytes, so this leaves an order of magnitude of headroom while
/// still bounding what one request can make the server hold.
pub const MAX_BODY_BYTES: usize = 16 * 1024 * 1024;
#[derive(Clone)]
pub struct AppState(Arc<Inner>);
pub struct Inner {
pub config: Config,
db: Mutex<Connection>,
pub(crate) local: auth::local::LocalAuth,
pub(crate) oidc: Option<auth::oidc::Oidc>,
}
impl AppState {
pub fn new(config: Config, conn: Connection) -> anyhow::Result<Self> {
let oidc = config.oidc.clone().map(auth::oidc::Oidc::new).transpose()?;
Ok(Self(Arc::new(Inner {
config,
db: Mutex::new(conn),
local: auth::local::LocalAuth::new(),
oidc,
})))
}
pub fn config(&self) -> &Config {
&self.0.config
}
/// The database. Never hold this across an `.await`.
pub fn db(&self) -> MutexGuard<'_, Connection> {
// A panic while holding the lock leaves nothing half-done that SQLite's
// own transactions don't already roll back, so carry on.
self.0.db.lock().unwrap_or_else(|e| e.into_inner())
}
}
pub fn router(state: AppState) -> Router {
Router::new()
.route("/healthz", get(|| async { "ok" }))
.route("/api/info", get(info))
.route("/api/library", get(library::get_library).put(library::put_library))
.route("/api/me", get(auth::me))
.route("/api/devices/{id}", delete(auth::revoke_device))
.route("/auth/start", get(auth::start))
.route("/auth/password", post(auth::local::submit))
.route("/auth/oidc", get(auth::oidc::begin))
.route("/auth/callback", get(auth::oidc::callback))
.route("/auth/token", post(auth::token))
.route("/auth/logout", post(auth::logout))
.layer(axum::extract::DefaultBodyLimit::max(MAX_BODY_BYTES))
.with_state(state)
}
/// Unauthenticated, so the app can check that an address really is an mtgc
/// server before sending the user's browser there.
async fn info(axum::extract::State(state): axum::extract::State<AppState>) -> Json<serde_json::Value> {
let c = state.config();
Json(serde_json::json!({
"server": "mtgc",
"api": 1,
"local_auth": c.local_auth,
"oidc": c.oidc.as_ref().map(|o| o.display_name.clone()),
}))
}
/// An error answered as `{"error": "..."}` with a status code.
#[derive(Debug)]
pub struct ApiError(pub StatusCode, pub String);
impl ApiError {
pub fn new(status: StatusCode, msg: impl Into<String>) -> Self {
Self(status, msg.into())
}
pub fn unauthorized() -> Self {
Self::new(StatusCode::UNAUTHORIZED, "not signed in")
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(self.0, Json(serde_json::json!({ "error": self.1 }))).into_response()
}
}
impl From<rusqlite::Error> for ApiError {
fn from(e: rusqlite::Error) -> Self {
tracing::error!("database: {e}");
Self::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
}
}
pub(crate) fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
fn random_bytes<const N: usize>() -> [u8; N] {
let mut buf = [0u8; N];
getrandom::fill(&mut buf).expect("the OS random number generator failed");
buf
}
pub(crate) fn random_hex(bytes: usize) -> String {
let buf: [u8; 32] = random_bytes();
hex(&buf[..bytes.min(32)])
}
/// A 256-bit secret, URL-safe.
pub(crate) fn random_token() -> String {
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(random_bytes::<32>())
}
/// Tokens and codes are stored only as hashes, so a leaked database can't be
/// used to impersonate a device. SHA-256 rather than a password hash because
/// they are already 256 random bits — there is nothing to brute-force.
pub(crate) fn sha256_hex(s: &str) -> String {
hex(&Sha256::digest(s.as_bytes()))
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}

View File

@@ -0,0 +1,77 @@
//! The one resource: the signed-in user's library document.
//!
//! `GET` returns `{version, doc}`. `PUT` replaces the document only when its
//! `If-Match` names the current version, answering `412` with the current
//! `{version, doc}` otherwise, so the device can merge against it at once.
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use mtgc_core::sync::LibraryDoc;
use serde_json::{json, Value};
use crate::auth::Authed;
use crate::db::{self, PutResult};
use crate::{ApiError, AppState};
pub async fn get_library(State(state): State<AppState>, auth: Authed) -> Result<Response, ApiError> {
let stored = db::get_library(&state.db(), auth.user.id)?;
Ok(Json(wire(stored)?).into_response())
}
pub async fn put_library(
State(state): State<AppState>,
auth: Authed,
headers: HeaderMap,
body: String,
) -> Result<Response, ApiError> {
let if_match = if_match(&headers)?;
// Validate with the app's own parser, so the server never stores anything
// a device would refuse to read back — including a format newer than
// this server understands.
let doc = LibraryDoc::from_json(&body)
.map_err(|e| ApiError::new(StatusCode::BAD_REQUEST, format!("not a library: {e}")))?;
// Store it normalised rather than as sent: same content, same bytes.
let text = serde_json::to_string(&doc).map_err(|e| ApiError::new(StatusCode::BAD_REQUEST, e.to_string()))?;
let result = db::put_library(&state.db(), auth.user.id, if_match, &text)?;
Ok(match result {
PutResult::Stored(version) => Json(json!({ "version": version })).into_response(),
PutResult::Conflict(version, doc) => {
(StatusCode::PRECONDITION_FAILED, Json(wire(doc.map(|d| (version, d)))?)).into_response()
}
})
}
/// `{version, doc}`, with an empty library at version 0 for a user who has
/// never stored one.
fn wire(stored: Option<(i64, String)>) -> Result<Value, ApiError> {
Ok(match stored {
Some((version, text)) => {
let doc: Value = serde_json::from_str(&text).map_err(|e| {
tracing::error!("stored library is unreadable: {e}");
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "stored library is unreadable")
})?;
json!({ "version": version, "doc": doc })
}
None => json!({ "version": 0, "doc": LibraryDoc::default() }),
})
}
/// The version a write replaces. Required: a write without one could only be
/// a blind overwrite, which is exactly what this API exists to prevent.
fn if_match(headers: &HeaderMap) -> Result<i64, ApiError> {
let raw = headers
.get("if-match")
.ok_or_else(|| {
ApiError::new(StatusCode::PRECONDITION_REQUIRED, "If-Match with the version being replaced is required")
})?
.to_str()
.unwrap_or_default()
.trim();
let raw = raw.strip_prefix("W/").unwrap_or(raw).trim_matches('"');
raw.parse()
.map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "If-Match must be a version number"))
}

View File

@@ -0,0 +1,170 @@
//! `mtgc-server` — run the sync server, or manage its local accounts.
//!
//! ```text
//! mtgc-server serve (the default)
//! mtgc-server user add <name> [--display-name <text>]
//! mtgc-server user passwd <name>
//! mtgc-server user remove <name> --yes
//! mtgc-server user list
//! ```
//!
//! The `user` commands read the same `MTGC_DB` as the server, so in a container
//! they run with `docker compose exec mtgc-server mtgc-server user add me`.
use std::io::{BufRead, IsTerminal};
use anyhow::{bail, Context};
use mtgc_server::auth::local::{hash_password, normalize_username};
use mtgc_server::{db, router, AppState, Config};
const USAGE: &str = "usage:
mtgc-server run the server
mtgc-server user add <name> [--display-name <text>]
mtgc-server user passwd <name>
mtgc-server user remove <name> --yes
mtgc-server user list";
fn main() -> anyhow::Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
match args.first().map(String::as_str) {
None | Some("serve") => serve(),
Some("user") => user(&args[1..]),
Some("-h" | "--help" | "help") => {
println!("{USAGE}");
Ok(())
}
Some(other) => bail!("unknown command {other:?}\n{USAGE}"),
}
}
fn serve() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "mtgc_server=info".into()),
)
.init();
let config = Config::from_env()?;
let conn = db::open(&config.db_path)
.with_context(|| format!("opening {}", config.db_path.display()))?;
let bind = config.bind;
tracing::info!(
public_url = %config.public_url,
local_auth = config.local_auth,
oidc = config.oidc.as_ref().map(|o| o.issuer.as_str()).unwrap_or("off"),
db = %config.db_path.display(),
"starting"
);
let state = AppState::new(config, conn)?;
tokio::runtime::Builder::new_multi_thread().enable_all().build()?.block_on(async move {
let listener = tokio::net::TcpListener::bind(bind)
.await
.with_context(|| format!("binding {bind}"))?;
tracing::info!("listening on {bind}");
axum::serve(listener, router(state))
.with_graceful_shutdown(async {
let _ = tokio::signal::ctrl_c().await;
})
.await?;
Ok(())
})
}
/// Account management needs only the database, not the rest of the server's
/// configuration, so it works before OIDC (say) is set up.
fn open_db() -> anyhow::Result<rusqlite::Connection> {
let path = std::env::var("MTGC_DB").unwrap_or_else(|_| "mtgc-server.db".into());
db::open(std::path::Path::new(&path)).with_context(|| format!("opening {path}"))
}
fn user(args: &[String]) -> anyhow::Result<()> {
let name = |i: usize| -> anyhow::Result<String> {
let n = normalize_username(args.get(i).map(String::as_str).unwrap_or_default());
if n.is_empty() {
bail!("a username is required\n{USAGE}");
}
if n.len() > 64 || n.chars().any(|c| c.is_whitespace() || c.is_control()) {
bail!("usernames are up to 64 characters, without spaces");
}
Ok(n)
};
match args.first().map(String::as_str) {
Some("add") => {
let username = name(1)?;
let display_name = flag(args, "--display-name").unwrap_or_else(|| username.clone());
let conn = open_db()?;
if db::local_user(&conn, &username)?.is_some() {
bail!("{username} already exists — use `user passwd` to change the password");
}
let hash = hash_password(&read_new_password()?).map_err(anyhow::Error::msg)?;
db::create_local_user(&conn, &username, &display_name, &hash)?;
println!("Created {username}.");
}
Some("passwd") => {
let username = name(1)?;
let conn = open_db()?;
if db::local_user(&conn, &username)?.is_none() {
bail!("no local account named {username}");
}
let hash = hash_password(&read_new_password()?).map_err(anyhow::Error::msg)?;
db::set_local_password(&conn, &username, &hash)?;
println!("Password changed for {username}. Devices already signed in stay signed in.");
}
Some("remove") => {
let username = name(1)?;
if !args.iter().any(|a| a == "--yes") {
bail!(
"this deletes {username}'s account, signs out their devices, and deletes \
the library stored on this server (devices keep their own copies). \
Re-run with --yes to confirm."
);
}
if db::delete_local_user(&open_db()?, &username)? {
println!("Removed {username}.");
} else {
bail!("no local account named {username}");
}
}
Some("list") => {
let users = db::list_users(&open_db()?)?;
if users.is_empty() {
println!("No accounts yet. Add one with `mtgc-server user add <name>`.");
}
for (u, devices) in users {
let kind = if u.iss == db::LOCAL_ISS { "local".to_string() } else { format!("oidc {}", u.iss) };
let who = if u.email.is_empty() { u.display_name } else { format!("{} <{}>", u.display_name, u.email) };
println!("{:<24} {:<8} devices: {:<3} {}", u.sub, kind, devices, who);
}
}
_ => bail!("{USAGE}"),
}
Ok(())
}
fn flag(args: &[String], name: &str) -> Option<String> {
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned()
}
/// Prompt twice without echo at a terminal; otherwise read one line from
/// stdin, so it can be scripted (`echo "$PW" | mtgc-server user add me`).
fn read_new_password() -> anyhow::Result<String> {
let password = if std::io::stdin().is_terminal() {
let first = rpassword::prompt_password("New password: ")?;
let again = rpassword::prompt_password("Again: ")?;
if first != again {
bail!("the passwords didn't match");
}
first
} else {
let mut line = String::new();
std::io::stdin().lock().read_line(&mut line)?;
line.trim_end_matches(['\r', '\n']).to_string()
};
if password.chars().count() < 8 {
bail!("use at least 8 characters");
}
Ok(password)
}

View File

@@ -0,0 +1,158 @@
//! The few HTML pages the server shows during sign-in.
//!
//! Hand-written and script-free: three small pages don't justify a template
//! engine, and with no JavaScript the content security policy can forbid it
//! outright.
use axum::http::header::{CACHE_CONTROL, CONTENT_SECURITY_POLICY, X_FRAME_OPTIONS};
use axum::http::{HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use crate::db::PendingLogin;
/// The sign-in page: a username/password form, plus a button for the
/// identity provider when one is configured. `username` refills the form after
/// a failed attempt, so only the password needs retyping.
pub fn login(
pending: &PendingLogin,
oidc_name: Option<&str>,
error: Option<&str>,
username: &str,
) -> Response {
let error = error
.map(|e| format!(r#"<p class="error" role="alert">{}</p>"#, esc(e)))
.unwrap_or_default();
let oidc = oidc_name
.map(|name| {
format!(
r#"<a class="button secondary" href="/auth/oidc?pending={id}">Sign in with {name}</a>
<div class="or"><span>or</span></div>"#,
id = esc(&pending.id),
name = esc(name),
)
})
.unwrap_or_default();
let body = format!(
r#"<h1>Sign in to mtgc</h1>
<p class="muted">Signing in <strong>{device}</strong> to sync your library.</p>
{error}
{oidc}
<form method="post" action="/auth/password">
<input type="hidden" name="pending" value="{id}">
<label>Username <input name="username" value="{username}" autocomplete="username" autocapitalize="none" required{focus_user}></label>
<label>Password <input name="password" type="password" autocomplete="current-password" required{focus_password}></label>
<button type="submit">Sign in</button>
</form>"#,
device = esc(&pending.device_name),
id = esc(&pending.id),
username = esc(username),
focus_user = if username.is_empty() { " autofocus" } else { "" },
focus_password = if username.is_empty() { "" } else { " autofocus" },
);
page(StatusCode::OK, "Sign in", &body, "")
}
/// Sign-in finished: send the browser back to the app.
///
/// A page rather than a bare redirect, because a phone browser may decline to
/// follow a redirect into an app on its own; the button then gives the user a
/// tap that it will honour.
pub fn return_to_app(url: &str) -> Response {
let url = esc(url);
let body = format!(
r#"<h1>Returning to mtgc…</h1>
<p class="muted">If the app doesn't open by itself, use the button below. You can close this tab afterwards.</p>
<a class="button" href="{url}">Return to mtgc</a>"#
);
// Meta refresh rather than a 30x, so the page above is what's left showing
// if the hand-off doesn't happen.
let refresh = format!(r#"<meta http-equiv="refresh" content="0;url={url}">"#);
page(StatusCode::OK, "Signed in", &body, &refresh)
}
pub fn error(status: StatusCode, message: &str) -> Response {
let body = format!(
r#"<h1>Sign-in didn't work</h1>
<p>{}</p>"#,
esc(message)
);
page(status, "Sign-in problem", &body, "")
}
fn page(status: StatusCode, title: &str, body: &str, head_extra: &str) -> Response {
let mut resp = (status, Html(render(title, body, head_extra))).into_response();
let h = resp.headers_mut();
h.insert(
CONTENT_SECURITY_POLICY,
HeaderValue::from_static("default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'"),
);
// The password page must never be framed by another site.
h.insert(X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
// Each page embeds a single-use id or code.
h.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
resp
}
fn render(title: &str, body: &str, head_extra: &str) -> String {
format!(
r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="no-referrer">
{head_extra}
<title>{title} · mtgc</title>
<style>{STYLE}</style>
</head>
<body><main>{body}</main></body>
</html>"#,
title = esc(title),
)
}
/// Matches the app's terracotta brand colour, light and dark.
const STYLE: &str = r#"
:root { --bg:#faf7f5; --card:#fff; --fg:#2b2320; --muted:#6f625c; --accent:#c8553d; --accent-fg:#fff; --line:#e6ddd8; --err:#a12d1c; color-scheme: light dark; }
@media (prefers-color-scheme: dark) { :root { --bg:#1c1816; --card:#262120; --fg:#f1e9e5; --muted:#b3a59e; --line:#3d3532; --err:#ff8f7a; } }
* { box-sizing: border-box; }
body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center; background:var(--bg); color:var(--fg); font:16px/1.5 system-ui, sans-serif; padding:16px; }
main { width:100%; max-width:380px; background:var(--card); border:1px solid var(--line); border-radius:12px; padding:28px 24px; }
h1 { font-size:1.35rem; margin:0 0 8px; }
.muted { color:var(--muted); margin:0 0 20px; }
.error { color:var(--err); margin:0 0 16px; }
form { display:flex; flex-direction:column; gap:14px; }
label { display:flex; flex-direction:column; gap:6px; font-size:.9rem; color:var(--muted); }
input { font:inherit; color:var(--fg); background:var(--bg); padding:10px 12px; border:1px solid var(--line); border-radius:8px; }
input:focus { outline:2px solid var(--accent); outline-offset:1px; }
button, .button { display:block; width:100%; text-align:center; font:inherit; font-weight:600; padding:11px; border-radius:8px; border:1px solid var(--accent); background:var(--accent); color:var(--accent-fg); cursor:pointer; text-decoration:none; }
.button.secondary { background:transparent; color:var(--accent); }
.or { display:flex; align-items:center; gap:10px; color:var(--muted); font-size:.85rem; margin:18px 0; }
.or::before, .or::after { content:""; flex:1; border-top:1px solid var(--line); }
"#;
/// Escape text for HTML content and double-quoted attributes.
fn esc(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&#39;"),
c => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escaping_neutralises_markup_and_attribute_breakouts() {
assert_eq!(esc(r#"<b>"x" & 'y'</b>"#), "&lt;b&gt;&quot;x&quot; &amp; &#39;y&#39;&lt;/b&gt;");
}
}

View File

@@ -0,0 +1,475 @@
//! The server driven through its router in-process: sign-in end to end, the
//! library's compare-and-swap, and device revocation.
use axum::body::Body;
use axum::http::{header, Request, Response, StatusCode};
use axum::Router;
use http_body_util::BodyExt;
use mtgc_server::auth::local::hash_password;
use mtgc_server::auth::pkce_challenge;
use mtgc_server::{db, router, AppState, Config};
use serde_json::{json, Value};
use tower::ServiceExt;
const VERIFIER: &str = "a-perfectly-good-pkce-verifier-that-is-long-enough-0123456789";
const REDIRECT: &str = "http://127.0.0.1:43210/callback";
struct Server {
state: AppState,
}
impl Server {
fn new(vars: &[(&str, &str)]) -> Self {
let mut all = vec![("MTGC_PUBLIC_URL", "https://mtgc.example")];
all.extend_from_slice(vars);
let config = Config::from_lookup(|k| {
all.iter().find(|(name, _)| *name == k).map(|(_, v)| v.to_string())
})
.unwrap();
let state = AppState::new(config, db::open_in_memory().unwrap()).unwrap();
Self { state }
}
fn local() -> Self {
let s = Self::new(&[]);
s.add_user("alice", "correct horse battery");
s
}
fn add_user(&self, name: &str, password: &str) {
let hash = hash_password(password).unwrap();
db::create_local_user(&self.state.db(), name, name, &hash).unwrap();
}
fn app(&self) -> Router {
router(self.state.clone())
}
async fn send(&self, req: Request<Body>) -> Response<Body> {
self.app().oneshot(req).await.unwrap()
}
/// Open the sign-in page as the app would. Returns the pending id (from
/// the cookie that binds it to this "browser") and the page.
async fn start(&self) -> (String, Response<Body>) {
let uri = format!(
"/auth/start?redirect_uri={}&code_challenge={}&code_challenge_method=S256&state=app-state&device_name=Test%20phone",
urlencode(REDIRECT),
pkce_challenge(VERIFIER),
);
let resp = self.send(get(&uri)).await;
let cookie = resp
.headers()
.get(header::SET_COOKIE)
.expect("the sign-in should be bound to the browser")
.to_str()
.unwrap()
.to_string();
let pending = cookie
.split(';')
.next()
.unwrap()
.strip_prefix("mtgc_login=")
.unwrap()
.to_string();
(pending, resp)
}
async fn submit_password(&self, pending: &str, cookie: Option<&str>, user: &str, pw: &str) -> String {
let body = format!(
"pending={}&username={}&password={}",
urlencode(pending),
urlencode(user),
urlencode(pw)
);
let mut req = Request::post("/auth/password")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
if let Some(c) = cookie {
req = req.header(header::COOKIE, format!("mtgc_login={c}"));
}
text(self.send(req.body(Body::from(body)).unwrap()).await).await
}
/// Sign in with a password and return the code the app would receive.
async fn code_for(&self, user: &str, pw: &str) -> String {
let (pending, _) = self.start().await;
let page = self.submit_password(&pending, Some(&pending), user, pw).await;
code_from(&page).unwrap_or_else(|| panic!("no code in:\n{page}"))
}
async fn redeem(&self, code: &str, verifier: &str) -> Response<Body> {
self.send(post_json("/auth/token", json!({ "code": code, "code_verifier": verifier }), None))
.await
}
async fn sign_in(&self, user: &str, pw: &str) -> String {
let code = self.code_for(user, pw).await;
let resp = self.redeem(&code, VERIFIER).await;
assert_eq!(resp.status(), StatusCode::OK);
body_json(resp).await["token"].as_str().unwrap().to_string()
}
async fn get_library(&self, token: &str) -> Response<Body> {
self.send(
Request::get("/api/library")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
}
async fn put_library(&self, token: &str, if_match: Option<&str>, doc: Value) -> Response<Body> {
let mut req = Request::put("/api/library")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::CONTENT_TYPE, "application/json");
if let Some(v) = if_match {
req = req.header(header::IF_MATCH, v);
}
self.send(req.body(Body::from(doc.to_string())).unwrap()).await
}
}
fn get(uri: &str) -> Request<Body> {
Request::get(uri).body(Body::empty()).unwrap()
}
fn post_json(uri: &str, body: Value, token: Option<&str>) -> Request<Body> {
let mut req = Request::post(uri).header(header::CONTENT_TYPE, "application/json");
if let Some(t) = token {
req = req.header(header::AUTHORIZATION, format!("Bearer {t}"));
}
req.body(Body::from(body.to_string())).unwrap()
}
async fn text(resp: Response<Body>) -> String {
String::from_utf8(resp.into_body().collect().await.unwrap().to_bytes().to_vec()).unwrap()
}
async fn body_json(resp: Response<Body>) -> Value {
serde_json::from_str(&text(resp).await).unwrap()
}
fn urlencode(s: &str) -> String {
url::form_urlencoded::byte_serialize(s.as_bytes()).collect()
}
/// The code from the "returning to the app" page's link.
fn code_from(page: &str) -> Option<String> {
let start = page.find(r#"href=""#)? + 6;
let href = &page[start..start + page[start..].find('"')?];
let href = href.replace("&amp;", "&");
let u = url::Url::parse(&href).ok()?;
assert!(href.starts_with(REDIRECT), "code must go to the app: {href}");
assert_eq!(
u.query_pairs().find(|(k, _)| k == "state").map(|(_, v)| v.into_owned()).as_deref(),
Some("app-state"),
"the app's state must come back with the code"
);
u.query_pairs().find(|(k, _)| k == "code").map(|(_, v)| v.into_owned())
}
fn library(cards: &[(&str, i64)]) -> Value {
let cards: serde_json::Map<String, Value> =
cards.iter().map(|(id, q)| (id.to_string(), json!({ "quantity": q }))).collect();
json!({ "version": 1, "cards": cards, "decks": {} })
}
// --- sign-in ------------------------------------------------------------------
#[tokio::test]
async fn a_password_sign_in_yields_a_working_device_token() {
let s = Server::local();
let token = s.sign_in("alice", "correct horse battery").await;
let resp = s.get_library(&token).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
assert_eq!(body["version"], 0, "a new account starts empty");
assert_eq!(body["doc"]["cards"], json!({}));
let me = s
.send(Request::get("/api/me").header(header::AUTHORIZATION, format!("Bearer {token}")).body(Body::empty()).unwrap())
.await;
let me = body_json(me).await;
assert_eq!(me["user"]["username"], "alice");
assert_eq!(me["user"]["method"], "local");
assert_eq!(me["devices"][0]["name"], "Test phone");
assert_eq!(me["devices"][0]["current"], true);
}
#[tokio::test]
async fn usernames_are_not_case_sensitive() {
let s = Server::local();
s.sign_in(" Alice ", "correct horse battery").await;
}
#[tokio::test]
async fn a_wrong_password_and_an_unknown_user_look_the_same() {
let s = Server::local();
let (pending, _) = s.start().await;
let wrong = s.submit_password(&pending, Some(&pending), "alice", "nope").await;
let unknown = s.submit_password(&pending, Some(&pending), "mallory", "nope").await;
for page in [&wrong, &unknown] {
assert!(page.contains("Invalid username or password."));
assert!(code_from(page).is_none());
}
assert!(wrong.contains(r#"value="alice""#), "the username is kept for the retry");
}
#[tokio::test]
async fn repeated_failures_are_slowed_down() {
let s = Server::local();
let (pending, _) = s.start().await;
for _ in 0..5 {
s.submit_password(&pending, Some(&pending), "alice", "nope").await;
}
// Even the right password has to wait now.
let page = s.submit_password(&pending, Some(&pending), "alice", "correct horse battery").await;
assert!(page.contains("Too many attempts"), "{page}");
}
#[tokio::test]
async fn a_password_post_from_another_browser_is_refused() {
let s = Server::local();
let (pending, _) = s.start().await;
let no_cookie = s.submit_password(&pending, None, "alice", "correct horse battery").await;
let other = s.submit_password(&pending, Some("someone-elses"), "alice", "correct horse battery").await;
for page in [&no_cookie, &other] {
assert!(page.contains("expired"), "{page}");
assert!(code_from(page).is_none());
}
}
#[tokio::test]
async fn codes_need_the_right_verifier_and_work_once() {
let s = Server::local();
let code = s.code_for("alice", "correct horse battery").await;
assert_eq!(s.redeem(&code, "some-other-verifier-of-a-reasonable-length-0000000").await.status(), StatusCode::BAD_REQUEST);
assert_eq!(
s.redeem(&code, VERIFIER).await.status(),
StatusCode::BAD_REQUEST,
"a failed attempt burns the code"
);
let code = s.code_for("alice", "correct horse battery").await;
assert_eq!(s.redeem(&code, VERIFIER).await.status(), StatusCode::OK);
assert_eq!(s.redeem(&code, VERIFIER).await.status(), StatusCode::BAD_REQUEST, "single use");
}
#[tokio::test]
async fn an_expired_code_is_refused() {
let s = Server::local();
let code = s.code_for("alice", "correct horse battery").await;
s.state.db().execute("UPDATE auth_codes SET expires_at = 0", []).unwrap();
assert_eq!(s.redeem(&code, VERIFIER).await.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn codes_are_only_ever_sent_to_the_app() {
let s = Server::local();
for bad in [
"https://evil.example/callback",
"http://127.0.0.1.evil.example:80/callback",
"http://localhost:1234/callback",
"http://127.0.0.1:1234/elsewhere",
"mtgc://elsewhere",
] {
let uri = format!(
"/auth/start?redirect_uri={}&code_challenge={}&state=x",
urlencode(bad),
pkce_challenge(VERIFIER)
);
assert_eq!(s.send(get(&uri)).await.status(), StatusCode::BAD_REQUEST, "{bad}");
}
}
#[tokio::test]
async fn the_phone_apps_scheme_is_accepted() {
let s = Server::local();
let uri = format!(
"/auth/start?redirect_uri={}&code_challenge={}&state=x",
urlencode("mtgc://auth/callback"),
pkce_challenge(VERIFIER)
);
assert_eq!(s.send(get(&uri)).await.status(), StatusCode::OK);
}
#[tokio::test]
async fn with_only_oidc_the_server_skips_straight_to_the_provider() {
let s = Server::new(&[
("MTGC_LOCAL_AUTH", "false"),
("MTGC_OIDC_ISSUER", "https://id.example/realms/home"),
("MTGC_OIDC_CLIENT_ID", "mtgc"),
("MTGC_OIDC_CLIENT_SECRET", "secret"),
]);
let (pending, resp) = s.start().await;
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(
resp.headers()[header::LOCATION].to_str().unwrap(),
format!("/auth/oidc?pending={pending}")
);
// And the password form is gone, not just hidden.
let page = s.submit_password(&pending, Some(&pending), "alice", "whatever").await;
assert!(page.contains("turned off"), "{page}");
}
#[tokio::test]
async fn with_both_methods_the_page_offers_both() {
let s = Server::new(&[
("MTGC_OIDC_ISSUER", "https://id.example/realms/home"),
("MTGC_OIDC_CLIENT_ID", "mtgc"),
("MTGC_OIDC_CLIENT_SECRET", "secret"),
("MTGC_OIDC_NAME", "Authentik"),
]);
let (_, resp) = s.start().await;
let page = text(resp).await;
assert!(page.contains("Sign in with Authentik"));
assert!(page.contains(r#"action="/auth/password""#));
}
#[tokio::test]
async fn the_server_describes_itself() {
let s = Server::local();
let info = body_json(s.send(get("/api/info")).await).await;
assert_eq!(info["server"], "mtgc");
assert_eq!(info["local_auth"], true);
assert_eq!(info["oidc"], Value::Null);
}
// --- devices ------------------------------------------------------------------
#[tokio::test]
async fn the_library_needs_a_valid_token() {
let s = Server::local();
assert_eq!(s.send(get("/api/library")).await.status(), StatusCode::UNAUTHORIZED);
assert_eq!(s.get_library("mtgc_made-up").await.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn signing_out_revokes_only_that_device() {
let s = Server::local();
let phone = s.sign_in("alice", "correct horse battery").await;
let desktop = s.sign_in("alice", "correct horse battery").await;
let resp = s.send(post_json("/auth/logout", json!({}), Some(&phone))).await;
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
assert_eq!(s.get_library(&phone).await.status(), StatusCode::UNAUTHORIZED);
assert_eq!(s.get_library(&desktop).await.status(), StatusCode::OK);
}
#[tokio::test]
async fn a_lost_device_can_be_revoked_from_another_but_not_by_someone_else() {
let s = Server::local();
s.add_user("bob", "another fine password");
let lost = s.sign_in("alice", "correct horse battery").await;
let mine = s.sign_in("alice", "correct horse battery").await;
let bobs = s.sign_in("bob", "another fine password").await;
let me = s
.send(Request::get("/api/me").header(header::AUTHORIZATION, format!("Bearer {mine}")).body(Body::empty()).unwrap())
.await;
let devices = body_json(me).await["devices"].clone();
let lost_id = devices
.as_array()
.unwrap()
.iter()
.find(|d| d["current"] == false)
.unwrap()["id"]
.as_i64()
.unwrap();
let revoke = |token: String| {
Request::delete(format!("/api/devices/{lost_id}"))
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap()
};
assert_eq!(s.send(revoke(bobs)).await.status(), StatusCode::NOT_FOUND, "not bob's to revoke");
assert_eq!(s.get_library(&lost).await.status(), StatusCode::OK);
assert_eq!(s.send(revoke(mine)).await.status(), StatusCode::NO_CONTENT);
assert_eq!(s.get_library(&lost).await.status(), StatusCode::UNAUTHORIZED);
}
// --- the library ----------------------------------------------------------------
#[tokio::test]
async fn writes_must_name_the_version_they_replace() {
let s = Server::local();
let t = s.sign_in("alice", "correct horse battery").await;
let resp = s.put_library(&t, Some("\"0\""), library(&[("a", 1)])).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(body_json(resp).await["version"], 1);
// A second device still thinks it's at 0: refused, and told what is there.
let resp = s.put_library(&t, Some("0"), library(&[("b", 2)])).await;
assert_eq!(resp.status(), StatusCode::PRECONDITION_FAILED);
let current = body_json(resp).await;
assert_eq!(current["version"], 1);
assert_eq!(current["doc"]["cards"]["a"]["quantity"], 1);
let resp = s.put_library(&t, None, library(&[("b", 2)])).await;
assert_eq!(resp.status(), StatusCode::PRECONDITION_REQUIRED, "no blind overwrites");
let got = body_json(s.get_library(&t).await).await;
assert_eq!(got["version"], 1);
assert_eq!(got["doc"]["cards"]["a"]["quantity"], 1);
}
#[tokio::test]
async fn a_write_to_a_library_that_does_not_exist_yet_must_say_version_zero() {
let s = Server::local();
let t = s.sign_in("alice", "correct horse battery").await;
let resp = s.put_library(&t, Some("3"), library(&[("a", 1)])).await;
assert_eq!(resp.status(), StatusCode::PRECONDITION_FAILED);
assert_eq!(body_json(resp).await["version"], 0);
}
#[tokio::test]
async fn only_valid_libraries_are_stored() {
let s = Server::local();
let t = s.sign_in("alice", "correct horse battery").await;
let newer = json!({ "version": 99, "cards": {}, "decks": {} });
assert_eq!(s.put_library(&t, Some("0"), newer).await.status(), StatusCode::BAD_REQUEST);
assert_eq!(s.put_library(&t, Some("0"), json!(["nope"])).await.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn each_user_has_their_own_library() {
let s = Server::local();
s.add_user("bob", "another fine password");
let alice = s.sign_in("alice", "correct horse battery").await;
let bob = s.sign_in("bob", "another fine password").await;
s.put_library(&alice, Some("0"), library(&[("a", 1)])).await;
let bobs = body_json(s.get_library(&bob).await).await;
assert_eq!(bobs["version"], 0);
assert_eq!(bobs["doc"]["cards"], json!({}));
}
#[tokio::test]
async fn old_versions_are_kept_up_to_a_limit() {
let s = Server::local();
let t = s.sign_in("alice", "correct horse battery").await;
let total = db::HISTORY_KEEP + 10;
for v in 0..total {
let resp = s.put_library(&t, Some(&v.to_string()), library(&[("a", v)])).await;
assert_eq!(resp.status(), StatusCode::OK);
}
let user_id: i64 = s.state.db().query_row("SELECT id FROM users WHERE sub = 'alice'", [], |r| r.get(0)).unwrap();
let kept = db::history_versions(&s.state.db(), user_id).unwrap();
assert_eq!(kept.len() as i64, db::HISTORY_KEEP);
assert_eq!(*kept.last().unwrap(), total - 1, "the newest superseded version is kept");
}

View File

@@ -0,0 +1,57 @@
//! The `user` subcommands, run as the real binary against a scratch database.
use std::io::Write;
use std::process::{Command, Output, Stdio};
fn run(db: &std::path::Path, args: &[&str], stdin: &str) -> Output {
let mut child = Command::new(env!("CARGO_BIN_EXE_mtgc-server"))
.args(args)
.env("MTGC_DB", db)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child.stdin.take().unwrap().write_all(stdin.as_bytes()).unwrap();
child.wait_with_output().unwrap()
}
fn out(o: &Output) -> String {
format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))
}
#[test]
fn local_accounts_can_be_added_changed_listed_and_removed() {
let dir = std::env::temp_dir().join(format!("mtgc-server-cli-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let db = dir.join("server.db");
let o = run(&db, &["user", "add", "Alice", "--display-name", "Alice A."], "a long password\n");
assert!(o.status.success(), "{}", out(&o));
let o = run(&db, &["user", "add", "alice"], "a long password\n");
assert!(!o.status.success(), "duplicate names must be refused");
let o = run(&db, &["user", "add", "bob"], "short\n");
assert!(!o.status.success(), "short passwords must be refused");
let o = run(&db, &["user", "passwd", "alice"], "another long password\n");
assert!(o.status.success(), "{}", out(&o));
let conn = mtgc_server::db::open(&db).unwrap();
let (_, hash) = mtgc_server::db::local_user(&conn, "alice").unwrap().unwrap();
assert!(hash.starts_with("$argon2id$"));
drop(conn);
let o = run(&db, &["user", "list"], "");
assert!(out(&o).contains("alice") && out(&o).contains("Alice A."), "{}", out(&o));
let o = run(&db, &["user", "remove", "alice"], "");
assert!(!o.status.success(), "removal must be confirmed");
let o = run(&db, &["user", "remove", "alice", "--yes"], "");
assert!(o.status.success(), "{}", out(&o));
let o = run(&db, &["user", "list"], "");
assert!(!out(&o).contains("alice"), "{}", out(&o));
std::fs::remove_dir_all(&dir).unwrap();
}

View File

@@ -0,0 +1,161 @@
//! The app's sync client against the real server over HTTP: the wire protocol
//! end to end, with two devices that each keep a SQLite working copy.
use std::path::{Path, PathBuf};
use mtgc_core::sync::account::{redeem_code, server_info, start_url, Pkce};
use mtgc_core::sync::remote::{sync_cycle, HttpRemote, SyncBase, UNAUTHORIZED};
use mtgc_core::sync::{apply_doc, merge_into, read_doc, CardEntry};
use mtgc_server::auth::local::hash_password;
use mtgc_server::{db, router, AppState, Config};
/// Start a server on a free port and return its URL. It runs on its own thread
/// for the rest of the test process.
fn spawn_server() -> (String, AppState) {
let std_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}", std_listener.local_addr().unwrap());
let public = url.clone();
let config = Config::from_lookup(move |k| (k == "MTGC_PUBLIC_URL").then(|| public.clone())).unwrap();
let state = AppState::new(config, db::open_in_memory().unwrap()).unwrap();
let app = router(state.clone());
std_listener.set_nonblocking(true).unwrap();
std::thread::spawn(move || {
tokio::runtime::Runtime::new().unwrap().block_on(async move {
let listener = tokio::net::TcpListener::from_std(std_listener).unwrap();
axum::serve(listener, app).await.unwrap();
});
});
(url, state)
}
/// Sign in the way the app does, playing the browser's part by hand.
fn sign_in(server: &str, user: &str, password: &str) -> mtgc_core::sync::account::SignedIn {
let pkce = Pkce::new();
let start = start_url(server, "http://127.0.0.1:9/callback", &pkce, "st", "Test device");
let page = ureq::get(&start).call().unwrap();
let cookie = page.header("set-cookie").unwrap().split(';').next().unwrap().to_string();
let pending = cookie.strip_prefix("mtgc_login=").unwrap().to_string();
let done = ureq::post(&format!("{server}/auth/password"))
.set("Cookie", &cookie)
.send_form(&[("pending", &pending), ("username", user), ("password", password)])
.unwrap()
.into_string()
.unwrap();
let start = done.find("code=").expect("signed in") + 5;
let code = &done[start..start + done[start..].find('&').unwrap()];
redeem_code(server, code, &pkce.verifier).unwrap()
}
struct Device {
db: PathBuf,
base: SyncBase,
remote: HttpRemote,
}
impl Device {
fn new(dir: &Path, name: &str, server: &str, token: &str) -> Self {
Self {
db: dir.join(format!("{name}.db")),
base: SyncBase::default(),
remote: HttpRemote::new(server, token),
}
}
fn sync(&mut self) -> mtgc_core::Result<mtgc_core::sync::remote::CycleReport> {
let path = self.db.clone();
sync_cycle(&self.remote, &mut self.base, |base, theirs| {
let conn = mtgc_core::db::open_library(&path)?;
merge_into(&conn, base, theirs)
})
}
fn set(&self, id: &str, qty: i64) {
let conn = mtgc_core::db::open_library(&self.db).unwrap();
let mut doc = read_doc(&conn).unwrap();
doc.cards.insert(id.into(), CardEntry { quantity: qty, location: String::new() });
apply_doc(&conn, &doc).unwrap();
}
fn qty(&self, id: &str) -> Option<i64> {
let conn = mtgc_core::db::open_library(&self.db).unwrap();
read_doc(&conn).unwrap().cards.get(id).map(|c| c.quantity)
}
}
#[test]
fn two_devices_converge_through_the_real_server() {
let (server, state) = spawn_server();
let hash = hash_password("correct horse battery").unwrap();
db::create_local_user(&state.db(), "alice", "Alice", &hash).unwrap();
let dir = std::env::temp_dir().join(format!("mtgc-e2e-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let info = server_info(&server).unwrap();
assert!(info.local_auth);
let phone_login = sign_in(&server, "alice", "correct horse battery");
let desktop_login = sign_in(&server, "alice", "correct horse battery");
assert_eq!(phone_login.user.id, desktop_login.user.id, "same account, same id");
assert_eq!(phone_login.user.method, "local");
let mut phone = Device::new(&dir, "phone", &server, &phone_login.token);
let mut desktop = Device::new(&dir, "desktop", &server, &desktop_login.token);
phone.set("a", 1);
phone.set("b", 1);
assert!(phone.sync().unwrap().published);
desktop.sync().unwrap();
assert_eq!(desktop.qty("a"), Some(1));
// Offline edits on both, to different cards.
phone.set("a", 3);
desktop.set("b", 5);
phone.sync().unwrap();
desktop.sync().unwrap();
phone.sync().unwrap();
for d in [&phone, &desktop] {
assert_eq!(d.qty("a"), Some(3));
assert_eq!(d.qty("b"), Some(5));
}
assert!(!phone.sync().unwrap().published, "settled: nothing left to send");
// Revoking the phone from the desktop signs it out.
let me = desktop.remote.me().unwrap();
let phone_id = me.devices.iter().find(|d| !d.current).unwrap().id;
desktop.remote.revoke_device(phone_id).unwrap();
let err = phone.sync().unwrap_err();
assert_eq!(err.to_string(), UNAUTHORIZED);
desktop.remote.logout().unwrap();
assert!(desktop.sync().is_err());
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_app_and_server_agree_on_pkce() {
let p = Pkce::new();
assert_eq!(mtgc_server::auth::pkce_challenge(&p.verifier), p.challenge);
}
#[test]
fn an_address_that_is_not_an_mtgc_server_is_rejected() {
// Something that answers HTTP but isn't us.
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
use std::io::{Read, Write};
if let Ok((mut s, _)) = listener.accept() {
let mut buf = [0u8; 1024];
let _ = s.read(&mut buf);
let _ = s.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 5\r\n\r\nhello");
}
});
let err = server_info(&format!("http://{addr}")).unwrap_err();
assert!(err.to_string().contains("doesn't look like an mtgc sync server"), "{err}");
}