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