app_passwords.rs
⎇
Raw
1//! App passwords: the self-service routes, and what they do at the WebDAV
2//! mount.
3
4use crate::common::*;
5use axum::http::{Method, StatusCode};
6use serde_json::json;
7
8const ROUTE: &str = "/api/auth/app-passwords";
9
10/// A `PROPFIND` of the user mount, the way a mount client authenticates.
11async fn propfind(env: &Env, name: &str, password: &str) -> Resp {
12 req(
13 env,
14 "PROPFIND",
15 "/dav",
16 &basic(name, password),
17 &[("depth", "1")],
18 "",
19 )
20 .await
21}
22
23/// Create one and return its secret.
24async fn create(c: &Client, name: &str) -> String {
25 let r = c.post_json(ROUTE, &json!({ "name": name })).await;
26 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
27 r.json()["secret"].as_str().unwrap().to_string()
28}
29
30#[tokio::test]
31async fn an_app_password_mounts_an_account_that_requires_a_passkey() {
32 let env = Env::new().await;
33 let admin = env.admin().await;
34 let id = user_id(&admin, "admin").await;
35 let secret = create(&admin, "laptop").await;
36
37 let r = propfind(&env, "admin", &secret).await;
38 assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
39
40 // The secret names the account, so the Basic user name is not consulted.
41 let r = propfind(&env, "nobody", &secret).await;
42 assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
43
44 // The account password is deliberately not tried before the switch: a
45 // success would be cached for five minutes.
46 //
47 // `both` needs an existing passkey. Its contents never matter here.
48 env.state
49 .db
50 .add_passkey(id, b"cred-app-pw", "{}", "Test key")
51 .await
52 .unwrap()
53 .unwrap();
54 assert!(
55 env.state
56 .db
57 .set_user_auth_mode(id, api_types::AuthMode::Both)
58 .await
59 .unwrap()
60 );
61 assert_eq!(
62 propfind(&env, "admin", "admin1234").await.status,
63 StatusCode::UNAUTHORIZED
64 );
65 let r = propfind(&env, "admin", &secret).await;
66 assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
67}
68
69/// Pins the claim the UI makes: an app password signs in to WebDAV only.
70#[tokio::test]
71async fn an_app_password_opens_webdav_and_nothing_else() {
72 let env = Env::new().await;
73 let admin = env.admin().await;
74 let secret = create(&admin, "laptop").await;
75 let anon = Client::new(env.app.clone());
76
77 for name in ["admin", "nobody"] {
78 let r = anon
79 .post_json(
80 "/api/auth/login",
81 &json!({ "name": name, "password": secret }),
82 )
83 .await;
84 assert_eq!(r.status, StatusCode::UNAUTHORIZED, "login as {name}");
85 }
86
87 // The JSON API accepts no Basic at all.
88 let r = anon
89 .raw(
90 Method::GET,
91 "/api/files/1",
92 &[("authorization", &basic("admin", &secret))],
93 Vec::new(),
94 )
95 .await;
96 assert_eq!(r.status, StatusCode::UNAUTHORIZED, "{}", r.text());
97
98 // A control: the same secret does open the mount.
99 assert_eq!(
100 propfind(&env, "admin", &secret).await.status,
101 StatusCode::MULTI_STATUS
102 );
103}
104
105#[tokio::test]
106async fn revoking_one_closes_the_mount_immediately() {
107 let env = Env::new().await;
108 let admin = env.admin().await;
109 let secret = create(&admin, "laptop").await;
110 let id = admin.get(ROUTE).await.json()[0]["id"].as_i64().unwrap();
111
112 assert_eq!(
113 propfind(&env, "admin", &secret).await.status,
114 StatusCode::MULTI_STATUS
115 );
116
117 let r = admin.delete(&format!("{ROUTE}/{id}")).await;
118 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
119 // No verified-credential cache to age out, unlike the account password.
120 assert_eq!(
121 propfind(&env, "admin", &secret).await.status,
122 StatusCode::UNAUTHORIZED
123 );
124
125 let r = admin.delete(&format!("{ROUTE}/{id}")).await;
126 assert_eq!(r.status, StatusCode::NOT_FOUND);
127 assert_eq!(r.json()["code"], "err_app_password_not_found");
128 assert_eq!(
129 propfind(&env, "admin", "deadbeef").await.status,
130 StatusCode::UNAUTHORIZED
131 );
132}
133
134#[tokio::test]
135async fn the_list_never_shows_a_secret_and_the_count_is_capped() {
136 let env = Env::new().await;
137 let admin = env.admin().await;
138 create(&admin, " laptop ").await;
139
140 let listed = admin.get(ROUTE).await.json();
141 assert_eq!(listed[0]["name"], "laptop", "the label is trimmed");
142 assert_eq!(listed[0]["last_used_at"], json!(null));
143 assert!(
144 listed[0].get("secret").is_none(),
145 "the secret is only in the creating response: {listed}"
146 );
147
148 let r = admin.post_json(ROUTE, &json!({ "name": "" })).await;
149 assert_eq!(r.status, StatusCode::OK);
150 assert_eq!(r.json()["name"], "App password");
151 let r = admin
152 .post_json(ROUTE, &json!({ "name": "\u{1f511}".repeat(80) }))
153 .await;
154 assert_eq!(r.status, StatusCode::OK);
155 assert_eq!(
156 r.json()["name"].as_str().unwrap().chars().count(),
157 64,
158 "the bound is on characters, not bytes"
159 );
160
161 // Three exist already: the named one, the empty label, the long one.
162 for i in 4..=server::db::APP_PASSWORD_LIMIT {
163 create(&admin, &format!("client {i}")).await;
164 }
165 let r = admin.post_json(ROUTE, &json!({ "name": "eleven" })).await;
166 assert_eq!(r.status, StatusCode::BAD_REQUEST, "{}", r.text());
167 assert_eq!(r.json()["code"], "err_app_password_limit");
168}
169
170#[tokio::test]
171async fn app_passwords_are_private_to_their_account() {
172 let env = Env::new().await;
173 let admin = env.admin().await;
174 create_user(&admin, "bob", "bobpass12", &[("docs", "rw")]).await;
175 let bob = login(&env, "bob", "bobpass12").await;
176 let secret = create(&bob, "bobs laptop").await;
177 let bob_pw_id = bob.get(ROUTE).await.json()[0]["id"].as_i64().unwrap();
178
179 // An admin sees none of these and cannot delete one: the admin route is
180 // a password reset, not a credential browser.
181 assert_eq!(admin.get(ROUTE).await.json(), json!([]));
182 let r = admin.delete(&format!("{ROUTE}/{bob_pw_id}")).await;
183 assert_eq!(r.status, StatusCode::NOT_FOUND);
184 assert_eq!(
185 propfind(&env, "bob", &secret).await.status,
186 StatusCode::MULTI_STATUS
187 );
188
189 // Signing out is not revoking: a mount keeps working across sessions.
190 assert_eq!(
191 bob.post_json("/api/auth/logout", &json!({})).await.status,
192 StatusCode::OK
193 );
194 assert_eq!(
195 propfind(&env, "bob", &secret).await.status,
196 StatusCode::MULTI_STATUS
197 );
198
199 // An admin password reset does revoke it.
200 let bob_id = user_id(&admin, "bob").await;
201 let r = admin
202 .put_json(
203 &format!("/api/admin/users/{bob_id}"),
204 &json!({ "password": "rescued12" }),
205 )
206 .await;
207 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
208 assert_eq!(
209 propfind(&env, "bob", &secret).await.status,
210 StatusCode::UNAUTHORIZED
211 );
212 let bob = login(&env, "bob", "rescued12").await;
213 assert_eq!(bob.get(ROUTE).await.json(), json!([]));
214}
215
216#[tokio::test]
217async fn the_routes_need_a_session() {
218 let env = Env::new().await;
219 let _ = env.admin().await;
220 let anon = Client::new(env.app.clone());
221
222 assert_eq!(anon.get(ROUTE).await.status, StatusCode::UNAUTHORIZED);
223 assert_eq!(
224 anon.post_json(ROUTE, &json!({ "name": "x" })).await.status,
225 StatusCode::UNAUTHORIZED
226 );
227 assert_eq!(
228 anon.delete(&format!("{ROUTE}/1")).await.status,
229 StatusCode::UNAUTHORIZED
230 );
231}
232