Skip to main content

max / makenotwork

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