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