Skip to main content

max / makenotwork

10.8 KB · 338 lines History Blame Raw
1 //! Passkey / WebAuthn workflow tests: register, login, rename, delete.
2
3 use crate::harness::TestHarness;
4 use makenotwork::db::UserId;
5 use url::Url;
6 // `WebauthnAuthenticator` is a trait in 0.6, blanket-implemented on every
7 // `AuthenticatorBackend`, so the backend is held directly and the trait is
8 // imported only to bring do_registration/do_authentication into scope.
9 use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey};
10 use webauthn_rs_proto::{CreationChallengeResponse, RequestChallengeResponse};
11
12 const ORIGIN: &str = "http://localhost:3000";
13
14 /// Register a passkey for the currently logged-in user.
15 /// Returns (authenticator, credential_id_base64url), the credential ID is needed
16 /// because SoftPasskey doesn't support discoverable credentials (empty allowCredentials),
17 /// so we inject it into the authentication challenge.
18 async fn register_passkey(h: &mut TestHarness) -> (SoftPasskey, String) {
19 register_passkey_with_password(h, "Password1!").await
20 }
21
22 async fn register_passkey_with_password(
23 h: &mut TestHarness,
24 password: &str,
25 ) -> (SoftPasskey, String) {
26 let mut wa = SoftPasskey::new(true);
27
28 // Start registration (requires password confirmation)
29 let resp = h
30 .client
31 .post_form(
32 "/api/users/me/passkeys/register/start",
33 &format!("password={password}"),
34 )
35 .await;
36 assert_eq!(
37 resp.status.as_u16(),
38 200,
39 "register/start failed: {}",
40 resp.text
41 );
42 let ccr: CreationChallengeResponse = resp.json();
43
44 // Complete registration with the software authenticator
45 let origin = Url::parse(ORIGIN).unwrap();
46 let reg = wa
47 .do_registration(origin, ccr)
48 .expect("SoftPasskey registration failed");
49
50 // Capture the credential ID (base64url-encoded) before sending to server
51 let cred_id = reg.id.clone();
52
53 let resp = h
54 .client
55 .post_json(
56 "/api/users/me/passkeys/register/finish",
57 &serde_json::to_string(&reg).unwrap(),
58 )
59 .await;
60 assert_eq!(
61 resp.status.as_u16(),
62 200,
63 "register/finish failed: {}",
64 resp.text
65 );
66
67 (wa, cred_id)
68 }
69
70 /// Authenticate via passkey. SoftPasskey doesn't support discoverable credentials,
71 /// so we work around two limitations:
72 /// 1. Inject the credential ID into `allowCredentials` so SoftPasskey can find it
73 /// 2. Inject the user's UUID as `userHandle` in the response (SoftPasskey omits it)
74 ///
75 /// The server's discoverable verification accepts this because:
76 /// - `start_discoverable_authentication` places no restriction on which credential responds
77 /// - `identify_discoverable_authentication` uses the userHandle to find the user
78 /// - `finish_discoverable_authentication` verifies the cryptographic signature
79 async fn passkey_login(h: &mut TestHarness, wa: &mut SoftPasskey, cred_id: &str, user_id: UserId) {
80 // Load a page to establish a fresh session (needed after logout clears cookies)
81 h.client.get("/login").await;
82
83 // Get authentication challenge from server
84 let resp = h.client.post_json("/auth/passkey/start", "").await;
85 assert_eq!(
86 resp.status.as_u16(),
87 200,
88 "passkey/start failed: {}",
89 resp.text
90 );
91
92 // Inject credential ID into allowCredentials so SoftPasskey can find it
93 let mut rcr_json: serde_json::Value =
94 serde_json::from_str(&resp.text).expect("Failed to parse passkey/start response as JSON");
95 rcr_json["publicKey"]["allowCredentials"] = serde_json::json!([{
96 "type": "public-key",
97 "id": cred_id,
98 }]);
99 let rcr: RequestChallengeResponse = serde_json::from_value(rcr_json)
100 .expect("Failed to deserialize modified RequestChallengeResponse");
101
102 // Get the signed response from SoftPasskey
103 let origin = Url::parse(ORIGIN).unwrap();
104 let auth = wa
105 .do_authentication(origin, rcr)
106 .expect("SoftPasskey authentication failed");
107
108 // SoftPasskey doesn't set userHandle (it's not a discoverable authenticator).
109 // The server's identify_discoverable_authentication needs it to find the user.
110 // Inject the user's UUID as base64url-encoded userHandle.
111 use base64::Engine;
112 let user_handle_b64 =
113 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(user_id.as_uuid().as_bytes());
114 let mut auth_json = serde_json::to_value(&auth).unwrap();
115 auth_json["response"]["userHandle"] = serde_json::Value::String(user_handle_b64);
116
117 let resp = h
118 .client
119 .post_json("/auth/passkey/finish", &auth_json.to_string())
120 .await;
121 assert_eq!(
122 resp.status.as_u16(),
123 200,
124 "passkey/finish failed: {}",
125 resp.text
126 );
127 }
128
129 // ── Tests ──
130
131 #[tokio::test]
132 async fn passkey_register_and_list() {
133 let mut h = TestHarness::new().await;
134 h.signup("pkuser", "pkuser@test.com", "Password1!").await;
135
136 let (_wa, _cred_id) = register_passkey(&mut h).await;
137
138 // List, should show 1 passkey
139 let resp = h.client.get("/api/users/me/passkeys").await;
140 assert_eq!(resp.status.as_u16(), 200);
141 assert!(
142 resp.text.contains("Passkey"),
143 "Expected passkey in list: {}",
144 resp.text
145 );
146 }
147
148 #[tokio::test]
149 async fn passkey_full_registration_and_login() {
150 let mut h = TestHarness::new().await;
151 let user_id = h.signup("pklogin", "pklogin@test.com", "Password1!").await;
152
153 let (mut wa, cred_id) = register_passkey(&mut h).await;
154
155 h.client.post_form("/logout", "").await;
156
157 // Confirm logged out via /auth/me
158 let resp = h.client.get("/auth/me").await;
159 assert_eq!(
160 resp.status.as_u16(),
161 401,
162 "Should be unauthorized after logout"
163 );
164
165 // Login via passkey
166 passkey_login(&mut h, &mut wa, &cred_id, user_id).await;
167
168 // Verify we're logged in
169 let resp = h.client.get("/dashboard").await;
170 assert_eq!(
171 resp.status.as_u16(),
172 200,
173 "Dashboard should be accessible after passkey login"
174 );
175 }
176
177 #[tokio::test]
178 async fn passkey_rename() {
179 let mut h = TestHarness::new().await;
180 h.signup("pkrename", "pkrename@test.com", "Password1!")
181 .await;
182
183 let (_wa, _cred_id) = register_passkey(&mut h).await;
184
185 // List to find the passkey ID
186 let resp = h.client.get("/api/users/me/passkeys").await;
187 assert_eq!(resp.status.as_u16(), 200);
188
189 // Extract passkey ID from the HTML (data-id="..." attribute)
190 let id = extract_passkey_id(&resp.text).expect("Could not find passkey ID in list HTML");
191
192 // Rename it
193 let resp = h
194 .client
195 .put_form(&format!("/api/users/me/passkeys/{id}"), "name=My+YubiKey")
196 .await;
197 assert_eq!(resp.status.as_u16(), 200, "Rename failed: {}", resp.text);
198
199 // List again, should show new name
200 let resp = h.client.get("/api/users/me/passkeys").await;
201 assert!(
202 resp.text.contains("My YubiKey"),
203 "Renamed passkey not found in list: {}",
204 resp.text
205 );
206 }
207
208 #[tokio::test]
209 async fn passkey_delete_requires_password() {
210 let mut h = TestHarness::new().await;
211 h.signup("pkdel", "pkdel@test.com", "Password1!").await;
212
213 let (_wa, _cred_id) = register_passkey(&mut h).await;
214
215 let resp = h.client.get("/api/users/me/passkeys").await;
216 let id = extract_passkey_id(&resp.text).expect("Could not find passkey ID");
217
218 // Delete with wrong password, should fail
219 let resp = h
220 .client
221 .delete_form(&format!("/api/users/me/passkeys/{id}"), "password=wrong")
222 .await;
223 assert_eq!(
224 resp.status.as_u16(),
225 400,
226 "Delete with wrong password should be 400"
227 );
228
229 // Delete with correct password, should succeed
230 let resp = h
231 .client
232 .delete_form(
233 &format!("/api/users/me/passkeys/{id}"),
234 "password=Password1%21",
235 )
236 .await;
237 assert_eq!(
238 resp.status.as_u16(),
239 200,
240 "Delete with correct password failed: {}",
241 resp.text
242 );
243
244 // List, should be empty
245 let resp = h.client.get("/api/users/me/passkeys").await;
246 assert_eq!(resp.status.as_u16(), 200);
247 assert!(
248 extract_passkey_id(&resp.text).is_none(),
249 "Passkey list should be empty after deletion"
250 );
251 }
252
253 #[tokio::test]
254 async fn passkey_login_skips_totp() {
255 let mut h = TestHarness::new().await;
256 let user_id = h.signup("pk2fa", "pk2fa@test.com", "Password1!").await;
257
258 // Enable TOTP
259 let resp = h.client.post_form("/api/users/me/totp/setup", "").await;
260 assert_eq!(resp.status.as_u16(), 200);
261 let secret = extract_totp_secret(&resp.text);
262 let code = generate_totp_code(&secret, "pk2fa@test.com");
263 let resp = h
264 .client
265 .post_form("/api/users/me/totp/confirm", &format!("code={code}"))
266 .await;
267 assert_eq!(
268 resp.status.as_u16(),
269 200,
270 "TOTP confirm failed: {}",
271 resp.text
272 );
273
274 let (mut wa, cred_id) = register_passkey(&mut h).await;
275
276 h.client.post_form("/logout", "").await;
277
278 // Login via passkey, should skip TOTP entirely
279 passkey_login(&mut h, &mut wa, &cred_id, user_id).await;
280
281 // Should land on dashboard directly (no /auth/2fa redirect)
282 let resp = h.client.get("/dashboard").await;
283 assert_eq!(
284 resp.status.as_u16(),
285 200,
286 "Passkey login should skip TOTP and land on dashboard"
287 );
288 }
289
290 // ── Helpers ──
291
292 /// Extract the first passkey ID from list HTML. The template uses `data-id="..."` attributes.
293 fn extract_passkey_id(html: &str) -> Option<String> {
294 let marker = "data-id=\"";
295 let start = html.find(marker)? + marker.len();
296 let rest = &html[start..];
297 let end = rest.find('"')?;
298 let id = &rest[..end];
299 if id.is_empty() {
300 None
301 } else {
302 Some(id.to_string())
303 }
304 }
305
306 /// Extract the TOTP secret from setup HTML (inside `<details>` > `<code>`).
307 fn extract_totp_secret(html: &str) -> String {
308 let details_start = html
309 .find("<details")
310 .expect("No <details> in TOTP setup HTML");
311 let details_html = &html[details_start..];
312 let code_start = details_html.find("<code").expect("No <code> in details");
313 let after_tag = &details_html[code_start..];
314 let content_start = after_tag.find('>').expect("No > after <code") + 1;
315 let content_end = after_tag[content_start..]
316 .find("</code>")
317 .expect("No </code>");
318 after_tag[content_start..content_start + content_end].to_string()
319 }
320
321 /// Generate a valid TOTP code for the given base32 secret.
322 fn generate_totp_code(secret_base32: &str, email: &str) -> String {
323 let bytes = totp_rs::Secret::Encoded(secret_base32.to_string())
324 .to_bytes()
325 .expect("Invalid TOTP secret");
326 let totp = totp_rs::TOTP::new(
327 totp_rs::Algorithm::SHA1,
328 6,
329 1,
330 30,
331 bytes,
332 Some("Makenotwork".into()),
333 email.into(),
334 )
335 .expect("TOTP creation failed");
336 totp.generate_current().expect("TOTP generation failed")
337 }
338