Skip to main content

max / makenotwork

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