//! App passwords: the self-service routes, and what they do at the WebDAV //! mount. use crate::common::*; use axum::http::{Method, StatusCode}; use serde_json::json; const ROUTE: &str = "/api/auth/app-passwords"; /// A `PROPFIND` of the user mount, the way a mount client authenticates. async fn propfind(env: &Env, name: &str, password: &str) -> Resp { req( env, "PROPFIND", "/dav", &basic(name, password), &[("depth", "1")], "", ) .await } /// Create one and return its secret. async fn create(c: &Client, name: &str) -> String { let r = c.post_json(ROUTE, &json!({ "name": name })).await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); r.json()["secret"].as_str().unwrap().to_string() } #[tokio::test] async fn an_app_password_mounts_an_account_that_requires_a_passkey() { let env = Env::new().await; let admin = env.admin().await; let id = user_id(&admin, "admin").await; let secret = create(&admin, "laptop").await; let r = propfind(&env, "admin", &secret).await; assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text()); // The secret names the account, so the Basic user name is not consulted. let r = propfind(&env, "nobody", &secret).await; assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text()); // The account password is deliberately not tried before the switch: a // success would be cached for five minutes. // // `both` needs an existing passkey. Its contents never matter here. env.state .db .add_passkey(id, b"cred-app-pw", "{}", "Test key") .await .unwrap() .unwrap(); assert!( env.state .db .set_user_auth_mode(id, api_types::AuthMode::Both) .await .unwrap() ); assert_eq!( propfind(&env, "admin", "admin1234").await.status, StatusCode::UNAUTHORIZED ); let r = propfind(&env, "admin", &secret).await; assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text()); } /// Pins the claim the UI makes: an app password signs in to WebDAV only. #[tokio::test] async fn an_app_password_opens_webdav_and_nothing_else() { let env = Env::new().await; let admin = env.admin().await; let secret = create(&admin, "laptop").await; let anon = Client::new(env.app.clone()); for name in ["admin", "nobody"] { let r = anon .post_json( "/api/auth/login", &json!({ "name": name, "password": secret }), ) .await; assert_eq!(r.status, StatusCode::UNAUTHORIZED, "login as {name}"); } // The JSON API accepts no Basic at all. let r = anon .raw( Method::GET, "/api/files/1", &[("authorization", &basic("admin", &secret))], Vec::new(), ) .await; assert_eq!(r.status, StatusCode::UNAUTHORIZED, "{}", r.text()); // A control: the same secret does open the mount. assert_eq!( propfind(&env, "admin", &secret).await.status, StatusCode::MULTI_STATUS ); } #[tokio::test] async fn revoking_one_closes_the_mount_immediately() { let env = Env::new().await; let admin = env.admin().await; let secret = create(&admin, "laptop").await; let id = admin.get(ROUTE).await.json()[0]["id"].as_i64().unwrap(); assert_eq!( propfind(&env, "admin", &secret).await.status, StatusCode::MULTI_STATUS ); let r = admin.delete(&format!("{ROUTE}/{id}")).await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); // No verified-credential cache to age out, unlike the account password. assert_eq!( propfind(&env, "admin", &secret).await.status, StatusCode::UNAUTHORIZED ); let r = admin.delete(&format!("{ROUTE}/{id}")).await; assert_eq!(r.status, StatusCode::NOT_FOUND); assert_eq!(r.json()["code"], "err_app_password_not_found"); assert_eq!( propfind(&env, "admin", "deadbeef").await.status, StatusCode::UNAUTHORIZED ); } #[tokio::test] async fn the_list_never_shows_a_secret_and_the_count_is_capped() { let env = Env::new().await; let admin = env.admin().await; create(&admin, " laptop ").await; let listed = admin.get(ROUTE).await.json(); assert_eq!(listed[0]["name"], "laptop", "the label is trimmed"); assert_eq!(listed[0]["last_used_at"], json!(null)); assert!( listed[0].get("secret").is_none(), "the secret is only in the creating response: {listed}" ); let r = admin.post_json(ROUTE, &json!({ "name": "" })).await; assert_eq!(r.status, StatusCode::OK); assert_eq!(r.json()["name"], "App password"); let r = admin .post_json(ROUTE, &json!({ "name": "\u{1f511}".repeat(80) })) .await; assert_eq!(r.status, StatusCode::OK); assert_eq!( r.json()["name"].as_str().unwrap().chars().count(), 64, "the bound is on characters, not bytes" ); // Three exist already: the named one, the empty label, the long one. for i in 4..=server::db::APP_PASSWORD_LIMIT { create(&admin, &format!("client {i}")).await; } let r = admin.post_json(ROUTE, &json!({ "name": "eleven" })).await; assert_eq!(r.status, StatusCode::BAD_REQUEST, "{}", r.text()); assert_eq!(r.json()["code"], "err_app_password_limit"); } #[tokio::test] async fn app_passwords_are_private_to_their_account() { let env = Env::new().await; let admin = env.admin().await; create_user(&admin, "bob", "bobpass12", &[("docs", "rw")]).await; let bob = login(&env, "bob", "bobpass12").await; let secret = create(&bob, "bobs laptop").await; let bob_pw_id = bob.get(ROUTE).await.json()[0]["id"].as_i64().unwrap(); // An admin sees none of these and cannot delete one: the admin route is // a password reset, not a credential browser. assert_eq!(admin.get(ROUTE).await.json(), json!([])); let r = admin.delete(&format!("{ROUTE}/{bob_pw_id}")).await; assert_eq!(r.status, StatusCode::NOT_FOUND); assert_eq!( propfind(&env, "bob", &secret).await.status, StatusCode::MULTI_STATUS ); // Signing out is not revoking: a mount keeps working across sessions. assert_eq!( bob.post_json("/api/auth/logout", &json!({})).await.status, StatusCode::OK ); assert_eq!( propfind(&env, "bob", &secret).await.status, StatusCode::MULTI_STATUS ); // An admin password reset does revoke it. let bob_id = user_id(&admin, "bob").await; let r = admin .put_json( &format!("/api/admin/users/{bob_id}"), &json!({ "password": "rescued12" }), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); assert_eq!( propfind(&env, "bob", &secret).await.status, StatusCode::UNAUTHORIZED ); let bob = login(&env, "bob", "rescued12").await; assert_eq!(bob.get(ROUTE).await.json(), json!([])); } #[tokio::test] async fn the_routes_need_a_session() { let env = Env::new().await; let _ = env.admin().await; let anon = Client::new(env.app.clone()); assert_eq!(anon.get(ROUTE).await.status, StatusCode::UNAUTHORIZED); assert_eq!( anon.post_json(ROUTE, &json!({ "name": "x" })).await.status, StatusCode::UNAUTHORIZED ); assert_eq!( anon.delete(&format!("{ROUTE}/1")).await.status, StatusCode::UNAUTHORIZED ); }