//! 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) -> Response { 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) { 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 { 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 { 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 { 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 { Request::get(uri).body(Body::empty()).unwrap() } fn post_json(uri: &str, body: Value, token: Option<&str>) -> Request { 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) -> String { String::from_utf8(resp.into_body().collect().await.unwrap().to_bytes().to_vec()).unwrap() } async fn body_json(resp: Response) -> 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 { let start = page.find(r#"href=""#)? + 6; let href = &page[start..start + page[start..].find('"')?]; let href = href.replace("&", "&"); 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 = 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"); }