| 1 | + | //! End-to-end passkey ceremonies, driven by a software authenticator. |
| 2 | + | //! |
| 3 | + | //! A real passkey needs hardware and a human fingerprint, which no test can |
| 4 | + | //! supply — so this file *is* the authenticator: it holds a P-256 key, builds |
| 5 | + | //! the `authenticatorData` and `clientDataJSON` the spec describes, and signs |
| 6 | + | //! exactly what a security key would. Everything on the other side of the wire |
| 7 | + | //! is the real thing: the actual router, the actual handlers, the actual |
| 8 | + | //! verification. |
| 9 | + | //! |
| 10 | + | //! That makes it a genuine test of the flow — register a credential, then sign |
| 11 | + | //! in with it and get a session — plus the failures that matter: a forged |
| 12 | + | //! signature, a replayed challenge, someone else's credential. |
| 13 | + | |
| 14 | + | use anvil_core::{ |
| 15 | + | App, |
| 16 | + | Config, |
| 17 | + | sessions, |
| 18 | + | users, |
| 19 | + | }; |
| 20 | + | use axum::{ |
| 21 | + | Router, |
| 22 | + | body::Body, |
| 23 | + | http::{ |
| 24 | + | Request, |
| 25 | + | StatusCode, |
| 26 | + | header, |
| 27 | + | }, |
| 28 | + | }; |
| 29 | + | use base64::Engine; |
| 30 | + | use p256::ecdsa::{ |
| 31 | + | Signature, |
| 32 | + | SigningKey, |
| 33 | + | signature::Signer, |
| 34 | + | }; |
| 35 | + | use sha2::{ |
| 36 | + | Digest, |
| 37 | + | Sha256, |
| 38 | + | }; |
| 39 | + | use tower::ServiceExt; |
| 40 | + | |
| 41 | + | const ORIGIN: &str = "https://anvil.localhost"; |
| 42 | + | const RP_ID: &str = "anvil.localhost"; |
| 43 | + | |
| 44 | + | // --- the authenticator ----------------------------------------------------- |
| 45 | + | |
| 46 | + | /// A software stand-in for a security key: one credential, one P-256 key. |
| 47 | + | struct Authenticator { |
| 48 | + | key: SigningKey, |
| 49 | + | credential_id: Vec<u8>, |
| 50 | + | sign_count: u32, |
| 51 | + | } |
| 52 | + | |
| 53 | + | impl Authenticator { |
| 54 | + | fn new() -> Self { |
| 55 | + | let mut seed = [0u8; 32]; |
| 56 | + | getrandom(&mut seed); |
| 57 | + | let mut credential_id = vec![0u8; 32]; |
| 58 | + | getrandom(&mut credential_id); |
| 59 | + | Self { |
| 60 | + | key: SigningKey::from_bytes(&seed.into()).expect("random scalar is a valid key"), |
| 61 | + | credential_id, |
| 62 | + | sign_count: 0, |
| 63 | + | } |
| 64 | + | } |
| 65 | + | |
| 66 | + | /// `navigator.credentials.create()`: a `none`-attestation registration |
| 67 | + | /// response carrying the new credential's public key. |
| 68 | + | fn register(&self, challenge: &str) -> serde_json::Value { |
| 69 | + | let client_data = client_data("webauthn.create", challenge); |
| 70 | + | let auth_data = self.auth_data(true); |
| 71 | + | let attestation = cbor_map(vec![ |
| 72 | + | ( |
| 73 | + | ciborium::Value::Text("fmt".into()), |
| 74 | + | ciborium::Value::Text("none".into()), |
| 75 | + | ), |
| 76 | + | ( |
| 77 | + | ciborium::Value::Text("attStmt".into()), |
| 78 | + | ciborium::Value::Map(vec![]), |
| 79 | + | ), |
| 80 | + | ( |
| 81 | + | ciborium::Value::Text("authData".into()), |
| 82 | + | ciborium::Value::Bytes(auth_data), |
| 83 | + | ), |
| 84 | + | ]); |
| 85 | + | serde_json::json!({ |
| 86 | + | "id": b64url(&self.credential_id), |
| 87 | + | "rawId": b64url(&self.credential_id), |
| 88 | + | "type": "public-key", |
| 89 | + | "clientExtensionResults": {}, |
| 90 | + | "response": { |
| 91 | + | "clientDataJSON": b64url(client_data.as_bytes()), |
| 92 | + | "attestationObject": b64url(&attestation), |
| 93 | + | "transports": ["internal"], |
| 94 | + | }, |
| 95 | + | }) |
| 96 | + | } |
| 97 | + | |
| 98 | + | /// `navigator.credentials.get()`: an assertion over this challenge. |
| 99 | + | fn assert(&mut self, challenge: &str, user_handle: &[u8]) -> serde_json::Value { |
| 100 | + | self.sign_count += 1; |
| 101 | + | let client_data = client_data("webauthn.get", challenge); |
| 102 | + | let auth_data = self.auth_data(false); |
| 103 | + | |
| 104 | + | // What the authenticator actually signs: its own data, then the hash |
| 105 | + | // of what the browser told it about this request. |
| 106 | + | let mut signed = auth_data.clone(); |
| 107 | + | signed.extend_from_slice(&Sha256::digest(client_data.as_bytes())); |
| 108 | + | let signature: Signature = self.key.sign(&signed); |
| 109 | + | |
| 110 | + | serde_json::json!({ |
| 111 | + | "id": b64url(&self.credential_id), |
| 112 | + | "rawId": b64url(&self.credential_id), |
| 113 | + | "type": "public-key", |
| 114 | + | "clientExtensionResults": {}, |
| 115 | + | "response": { |
| 116 | + | "clientDataJSON": b64url(client_data.as_bytes()), |
| 117 | + | "authenticatorData": b64url(&auth_data), |
| 118 | + | "signature": b64url(signature.to_der().as_bytes()), |
| 119 | + | "userHandle": b64url(user_handle), |
| 120 | + | }, |
| 121 | + | }) |
| 122 | + | } |
| 123 | + | |
| 124 | + | /// `authenticatorData`: rpIdHash ‖ flags ‖ signCount, plus the attested |
| 125 | + | /// credential (and the credProtect extension anvil asks for) at |
| 126 | + | /// registration time. |
| 127 | + | fn auth_data(&self, registering: bool) -> Vec<u8> { |
| 128 | + | // UP (touched) | UV (verified) — anvil requires both. |
| 129 | + | let mut flags = 0x01 | 0x04; |
| 130 | + | if registering { |
| 131 | + | flags |= 0x40; // AT: attested credential data present |
| 132 | + | flags |= 0x80; // ED: extension data present |
| 133 | + | } |
| 134 | + | let mut data = Sha256::digest(RP_ID.as_bytes()).to_vec(); |
| 135 | + | data.push(flags); |
| 136 | + | data.extend_from_slice(&self.sign_count.to_be_bytes()); |
| 137 | + | if registering { |
| 138 | + | data.extend_from_slice(&[0u8; 16]); // AAGUID: zeroes, as privacy-preserving authenticators report |
| 139 | + | data.extend_from_slice(&(self.credential_id.len() as u16).to_be_bytes()); |
| 140 | + | data.extend_from_slice(&self.credential_id); |
| 141 | + | data.extend_from_slice(&self.cose_key()); |
| 142 | + | data.extend_from_slice(&cbor_map(vec![( |
| 143 | + | ciborium::Value::Text("credProtect".into()), |
| 144 | + | ciborium::Value::Integer(3.into()), // userVerificationRequired |
| 145 | + | )])); |
| 146 | + | } |
| 147 | + | data |
| 148 | + | } |
| 149 | + | |
| 150 | + | /// The public key as a COSE_Key: EC2 / P-256 / ES256. |
| 151 | + | fn cose_key(&self) -> Vec<u8> { |
| 152 | + | let point = self.key.verifying_key().to_encoded_point(false); |
| 153 | + | cbor_map(vec![ |
| 154 | + | ( |
| 155 | + | ciborium::Value::Integer(1.into()), // kty |
| 156 | + | ciborium::Value::Integer(2.into()), // EC2 |
| 157 | + | ), |
| 158 | + | ( |
| 159 | + | ciborium::Value::Integer(3.into()), // alg |
| 160 | + | ciborium::Value::Integer((-7).into()), // ES256 |
| 161 | + | ), |
| 162 | + | ( |
| 163 | + | ciborium::Value::Integer((-1).into()), // crv |
| 164 | + | ciborium::Value::Integer(1.into()), // P-256 |
| 165 | + | ), |
| 166 | + | ( |
| 167 | + | ciborium::Value::Integer((-2).into()), |
| 168 | + | ciborium::Value::Bytes(point.x().expect("uncompressed point has x").to_vec()), |
| 169 | + | ), |
| 170 | + | ( |
| 171 | + | ciborium::Value::Integer((-3).into()), |
| 172 | + | ciborium::Value::Bytes(point.y().expect("uncompressed point has y").to_vec()), |
| 173 | + | ), |
| 174 | + | ]) |
| 175 | + | } |
| 176 | + | } |
| 177 | + | |
| 178 | + | fn client_data(ceremony: &str, challenge: &str) -> String { |
| 179 | + | serde_json::json!({ |
| 180 | + | "type": ceremony, |
| 181 | + | "challenge": challenge, |
| 182 | + | "origin": ORIGIN, |
| 183 | + | "crossOrigin": false, |
| 184 | + | }) |
| 185 | + | .to_string() |
| 186 | + | } |
| 187 | + | |
| 188 | + | fn cbor_map(entries: Vec<(ciborium::Value, ciborium::Value)>) -> Vec<u8> { |
| 189 | + | let mut out = Vec::new(); |
| 190 | + | ciborium::into_writer(&ciborium::Value::Map(entries), &mut out).expect("CBOR encoding"); |
| 191 | + | out |
| 192 | + | } |
| 193 | + | |
| 194 | + | fn b64url(bytes: &[u8]) -> String { |
| 195 | + | base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) |
| 196 | + | } |
| 197 | + | |
| 198 | + | fn getrandom(buf: &mut [u8]) { |
| 199 | + | use argon2::password_hash::rand_core::{ |
| 200 | + | OsRng, |
| 201 | + | RngCore, |
| 202 | + | }; |
| 203 | + | OsRng.fill_bytes(buf); |
| 204 | + | } |
| 205 | + | |
| 206 | + | // --- harness --------------------------------------------------------------- |
| 207 | + | |
| 208 | + | struct Harness { |
| 209 | + | router: Router, |
| 210 | + | app: App, |
| 211 | + | cookie: String, |
| 212 | + | csrf: String, |
| 213 | + | user_id: i64, |
| 214 | + | _dir: tempfile::TempDir, |
| 215 | + | } |
| 216 | + | |
| 217 | + | async fn harness() -> Harness { |
| 218 | + | let dir = tempfile::tempdir().unwrap(); |
| 219 | + | let mut config = Config::default(); |
| 220 | + | config.data_dir = dir.path().to_path_buf(); |
| 221 | + | config.http.base_url = ORIGIN.to_string(); |
| 222 | + | let app = App::bootstrap(config).await.unwrap(); |
| 223 | + | let user = users::create(&app.db, "collin", "", "password", true) |
| 224 | + | .await |
| 225 | + | .unwrap(); |
| 226 | + | let session = sessions::create(&app.db, user.id).await.unwrap(); |
| 227 | + | let csrf = app.csrf_token(&session.token); |
| 228 | + | Harness { |
| 229 | + | router: anvil_web::router(app.clone()), |
| 230 | + | app, |
| 231 | + | cookie: format!("anvil_session={}", session.token), |
| 232 | + | csrf, |
| 233 | + | user_id: user.id, |
| 234 | + | _dir: dir, |
| 235 | + | } |
| 236 | + | } |
| 237 | + | |
| 238 | + | impl Harness { |
| 239 | + | /// POST JSON as the signed-in user (cookie + CSRF header). |
| 240 | + | async fn post_json(&self, path: &str, body: serde_json::Value) -> (StatusCode, String) { |
| 241 | + | self.send( |
| 242 | + | Request::post(path) |
| 243 | + | .header(header::COOKIE, &self.cookie) |
| 244 | + | .header("X-CSRF-Token", &self.csrf) |
| 245 | + | .header(header::CONTENT_TYPE, "application/json") |
| 246 | + | .body(Body::from(body.to_string())) |
| 247 | + | .unwrap(), |
| 248 | + | ) |
| 249 | + | .await |
| 250 | + | } |
| 251 | + | |
| 252 | + | /// POST JSON with no session at all, the way the login page does. |
| 253 | + | async fn post_anonymous(&self, path: &str, body: serde_json::Value) -> (StatusCode, String) { |
| 254 | + | self.send( |
| 255 | + | Request::post(path) |
| 256 | + | .header(header::CONTENT_TYPE, "application/json") |
| 257 | + | .body(Body::from(body.to_string())) |
| 258 | + | .unwrap(), |
| 259 | + | ) |
| 260 | + | .await |
| 261 | + | } |
| 262 | + | |
| 263 | + | async fn send(&self, request: Request<Body>) -> (StatusCode, String) { |
| 264 | + | let response = self.router.clone().oneshot(request).await.unwrap(); |
| 265 | + | let status = response.status(); |
| 266 | + | let body = axum::body::to_bytes(response.into_body(), 1 << 20) |
| 267 | + | .await |
| 268 | + | .unwrap(); |
| 269 | + | (status, String::from_utf8_lossy(&body).into_owned()) |
| 270 | + | } |
| 271 | + | |
| 272 | + | /// The login ceremony, returning the raw response so cookies can be read. |
| 273 | + | async fn login(&self, body: serde_json::Value) -> axum::response::Response { |
| 274 | + | self.router |
| 275 | + | .clone() |
| 276 | + | .oneshot( |
| 277 | + | Request::post("/-/login/passkey/finish") |
| 278 | + | .header(header::CONTENT_TYPE, "application/json") |
| 279 | + | .body(Body::from(body.to_string())) |
| 280 | + | .unwrap(), |
| 281 | + | ) |
| 282 | + | .await |
| 283 | + | .unwrap() |
| 284 | + | } |
| 285 | + | |
| 286 | + | /// Begin registration, returning (ceremony id, handle, challenge). |
| 287 | + | async fn begin_registration(&self) -> (String, String, String) { |
| 288 | + | let (status, body) = self |
| 289 | + | .post_json("/-/settings/passkeys/begin", serde_json::json!({})) |
| 290 | + | .await; |
| 291 | + | assert_eq!(status, StatusCode::OK, "begin failed: {body}"); |
| 292 | + | let json: serde_json::Value = serde_json::from_str(&body).unwrap(); |
| 293 | + | ( |
| 294 | + | json["ceremony"].as_str().unwrap().to_string(), |
| 295 | + | json["handle"].as_str().unwrap().to_string(), |
| 296 | + | json["options"]["challenge"].as_str().unwrap().to_string(), |
| 297 | + | ) |
| 298 | + | } |
| 299 | + | |
| 300 | + | /// Begin sign-in, returning (ceremony id, challenge). |
| 301 | + | async fn begin_login(&self) -> (String, String) { |
| 302 | + | let (status, body) = self |
| 303 | + | .post_anonymous("/-/login/passkey/begin", serde_json::json!({})) |
| 304 | + | .await; |
| 305 | + | assert_eq!(status, StatusCode::OK, "begin failed: {body}"); |
| 306 | + | let json: serde_json::Value = serde_json::from_str(&body).unwrap(); |
| 307 | + | ( |
| 308 | + | json["ceremony"].as_str().unwrap().to_string(), |
| 309 | + | json["options"]["challenge"].as_str().unwrap().to_string(), |
| 310 | + | ) |
| 311 | + | } |
| 312 | + | |
| 313 | + | /// Register `authenticator` and return the account's WebAuthn handle. |
| 314 | + | async fn register(&self, authenticator: &Authenticator, name: &str) -> Vec<u8> { |
| 315 | + | let (ceremony, handle, challenge) = self.begin_registration().await; |
| 316 | + | let (status, body) = self |
| 317 | + | .post_json( |
| 318 | + | "/-/settings/passkeys/finish", |
| 319 | + | serde_json::json!({ |
| 320 | + | "ceremony": ceremony, |
| 321 | + | "handle": handle, |
| 322 | + | "name": name, |
| 323 | + | "credential": authenticator.register(&challenge), |
| 324 | + | }), |
| 325 | + | ) |
| 326 | + | .await; |
| 327 | + | assert_eq!( |
| 328 | + | status, |
| 329 | + | StatusCode::NO_CONTENT, |
| 330 | + | "registration failed: {body}" |
| 331 | + | ); |
| 332 | + | base64::engine::general_purpose::STANDARD |
| 333 | + | .decode(&handle) |
| 334 | + | .unwrap() |
| 335 | + | } |
| 336 | + | } |
| 337 | + | |
| 338 | + | // --- the tests ------------------------------------------------------------- |
| 339 | + | |
| 340 | + | #[tokio::test] |
| 341 | + | async fn a_registered_passkey_signs_in() { |
| 342 | + | let harness = harness().await; |
| 343 | + | let mut authenticator = Authenticator::new(); |
| 344 | + | let handle = harness.register(&authenticator, "MacBook Touch ID").await; |
| 345 | + | |
| 346 | + | // The credential is stored against the account, with the label we gave it. |
| 347 | + | let stored = anvil_core::passkeys::list(&harness.app.db, harness.user_id) |
| 348 | + | .await |
| 349 | + | .unwrap(); |
| 350 | + | assert_eq!(stored.len(), 1); |
| 351 | + | assert_eq!(stored[0].name, "MacBook Touch ID"); |
| 352 | + | assert_eq!(stored[0].last_used_at, 0, "not used yet"); |
| 353 | + | |
| 354 | + | // Sign in with it: no username anywhere in this exchange. |
| 355 | + | let (ceremony, challenge) = harness.begin_login().await; |
| 356 | + | let response = harness |
| 357 | + | .login(serde_json::json!({ |
| 358 | + | "ceremony": ceremony, |
| 359 | + | "credential": authenticator.assert(&challenge, &handle), |
| 360 | + | })) |
| 361 | + | .await; |
| 362 | + | assert_eq!(response.status(), StatusCode::OK); |
| 363 | + | |
| 364 | + | // A session cookie comes back, and it belongs to the right account. |
| 365 | + | let cookie = response |
| 366 | + | .headers() |
| 367 | + | .get(header::SET_COOKIE) |
| 368 | + | .expect("session cookie") |
| 369 | + | .to_str() |
| 370 | + | .unwrap() |
| 371 | + | .to_string(); |
| 372 | + | let token = cookie |
| 373 | + | .split(';') |
| 374 | + | .next() |
| 375 | + | .unwrap() |
| 376 | + | .trim_start_matches("anvil_session=") |
| 377 | + | .to_string(); |
| 378 | + | let signed_in = sessions::lookup_user(&harness.app.db, &token) |
| 379 | + | .await |
| 380 | + | .unwrap() |
| 381 | + | .expect("the cookie names a live session"); |
| 382 | + | assert_eq!(signed_in.id, harness.user_id); |
| 383 | + | |
| 384 | + | // The sign-in is recorded against the credential. |
| 385 | + | let stored = anvil_core::passkeys::list(&harness.app.db, harness.user_id) |
| 386 | + | .await |
| 387 | + | .unwrap(); |
| 388 | + | assert!(stored[0].last_used_at > 0, "last use should be stamped"); |
| 389 | + | } |
| 390 | + | |
| 391 | + | #[tokio::test] |
| 392 | + | async fn a_second_passkey_shares_the_account_handle_and_is_excluded() { |
| 393 | + | let harness = harness().await; |
| 394 | + | let first = Authenticator::new(); |
| 395 | + | let handle = harness.register(&first, "laptop").await; |
| 396 | + | |
| 397 | + | // Registering another authenticator reuses the same user handle, so the |
| 398 | + | // account does not fork into two identities. |
| 399 | + | let (_, second_handle, _) = harness.begin_registration().await; |
| 400 | + | assert_eq!( |
| 401 | + | base64::engine::general_purpose::STANDARD |
| 402 | + | .decode(&second_handle) |
| 403 | + | .unwrap(), |
| 404 | + | handle |
| 405 | + | ); |
| 406 | + | |
| 407 | + | // …and the browser is told to refuse the already-registered credential. |
| 408 | + | let (status, body) = harness |
| 409 | + | .post_json("/-/settings/passkeys/begin", serde_json::json!({})) |
| 410 | + | .await; |
| 411 | + | assert_eq!(status, StatusCode::OK); |
| 412 | + | let json: serde_json::Value = serde_json::from_str(&body).unwrap(); |
| 413 | + | let excluded = json["options"]["excludeCredentials"].as_array().unwrap(); |
| 414 | + | assert_eq!(excluded.len(), 1); |
| 415 | + | assert_eq!( |
| 416 | + | excluded[0]["id"].as_str().unwrap(), |
| 417 | + | b64url(&first.credential_id) |
| 418 | + | ); |
| 419 | + | } |
| 420 | + | |
| 421 | + | #[tokio::test] |
| 422 | + | async fn a_forged_signature_is_refused() { |
| 423 | + | let harness = harness().await; |
| 424 | + | let mut authenticator = Authenticator::new(); |
| 425 | + | let handle = harness.register(&authenticator, "laptop").await; |
| 426 | + | |
| 427 | + | // Same credential id, a different key: what a stolen database plus a |
| 428 | + | // home-made authenticator would produce. |
| 429 | + | let (ceremony, challenge) = harness.begin_login().await; |
| 430 | + | let mut impostor = Authenticator::new(); |
| 431 | + | impostor.credential_id = authenticator.credential_id.clone(); |
| 432 | + | let response = harness |
| 433 | + | .login(serde_json::json!({ |
| 434 | + | "ceremony": ceremony, |
| 435 | + | "credential": impostor.assert(&challenge, &handle), |
| 436 | + | })) |
| 437 | + | .await; |
| 438 | + | assert_eq!(response.status(), StatusCode::UNAUTHORIZED); |
| 439 | + | assert!( |
| 440 | + | response.headers().get(header::SET_COOKIE).is_none(), |
| 441 | + | "a rejected sign-in must not set a session" |
| 442 | + | ); |
| 443 | + | |
| 444 | + | // The real authenticator still works afterwards. |
| 445 | + | let (ceremony, challenge) = harness.begin_login().await; |
| 446 | + | let response = harness |
| 447 | + | .login(serde_json::json!({ |
| 448 | + | "ceremony": ceremony, |
| 449 | + | "credential": authenticator.assert(&challenge, &handle), |
| 450 | + | })) |
| 451 | + | .await; |
| 452 | + | assert_eq!(response.status(), StatusCode::OK); |
| 453 | + | } |
| 454 | + | |
| 455 | + | #[tokio::test] |
| 456 | + | async fn a_captured_assertion_cannot_be_replayed() { |
| 457 | + | let harness = harness().await; |
| 458 | + | let mut authenticator = Authenticator::new(); |
| 459 | + | let handle = harness.register(&authenticator, "laptop").await; |
| 460 | + | |
| 461 | + | let (ceremony, challenge) = harness.begin_login().await; |
| 462 | + | let assertion = authenticator.assert(&challenge, &handle); |
| 463 | + | let first = harness |
| 464 | + | .login(serde_json::json!({ "ceremony": ceremony.clone(), "credential": assertion.clone() })) |
| 465 | + | .await; |
| 466 | + | assert_eq!(first.status(), StatusCode::OK); |
| 467 | + | |
| 468 | + | // Replaying the identical exchange fails: the challenge is spent. |
| 469 | + | let second = harness |
| 470 | + | .login(serde_json::json!({ "ceremony": ceremony, "credential": assertion })) |
| 471 | + | .await; |
| 472 | + | assert_eq!(second.status(), StatusCode::BAD_REQUEST); |
| 473 | + | } |
| 474 | + | |
| 475 | + | #[tokio::test] |
| 476 | + | async fn an_unregistered_passkey_cannot_sign_in() { |
| 477 | + | let harness = harness().await; |
| 478 | + | let mut stranger = Authenticator::new(); |
| 479 | + | let (ceremony, challenge) = harness.begin_login().await; |
| 480 | + | let response = harness |
| 481 | + | .login(serde_json::json!({ |
| 482 | + | "ceremony": ceremony, |
| 483 | + | "credential": stranger.assert(&challenge, &[7u8; 64]), |
| 484 | + | })) |
| 485 | + | .await; |
| 486 | + | assert_eq!(response.status(), StatusCode::UNAUTHORIZED); |
| 487 | + | } |
| 488 | + | |
| 489 | + | #[tokio::test] |
| 490 | + | async fn removing_a_passkey_revokes_it() { |
| 491 | + | let harness = harness().await; |
| 492 | + | let mut authenticator = Authenticator::new(); |
| 493 | + | let handle = harness.register(&authenticator, "laptop").await; |
| 494 | + | let stored = anvil_core::passkeys::list(&harness.app.db, harness.user_id) |
| 495 | + | .await |
| 496 | + | .unwrap(); |
| 497 | + | |
| 498 | + | let (status, _) = harness |
| 499 | + | .send( |
| 500 | + | Request::post(format!("/-/settings/passkeys/{}/delete", stored[0].id)) |
| 501 | + | .header(header::COOKIE, &harness.cookie) |
| 502 | + | .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") |
| 503 | + | .body(Body::from(format!("csrf={}", harness.csrf))) |
| 504 | + | .unwrap(), |
| 505 | + | ) |
| 506 | + | .await; |
| 507 | + | assert_eq!(status, StatusCode::SEE_OTHER); |
| 508 | + | |
| 509 | + | let (ceremony, challenge) = harness.begin_login().await; |
| 510 | + | let response = harness |
| 511 | + | .login(serde_json::json!({ |
| 512 | + | "ceremony": ceremony, |
| 513 | + | "credential": authenticator.assert(&challenge, &handle), |
| 514 | + | })) |
| 515 | + | .await; |
| 516 | + | assert_eq!(response.status(), StatusCode::UNAUTHORIZED); |
| 517 | + | } |