JanssenProject / JanssenProject/jans

feat(jans-fido2): report client-side passkey cancellation outcome to classify abandonment

Open
#14,744 0 comments 0 reactions 1 assignee Claimed by @imran-ishaq View on GitHub
comp-jans-auth-server comp-jans-fido2 enhancement kind-feature
Dominant language
Java
Stars
648
Forks
174
Avg merge
1d 18h
Merged PRs (30d)
110

Description

**Is your feature request related to a problem? Please describe.**

[#14737](https://github.com/JanssenProject/jans/issues/14737) made abandoned assertion ceremonies observable — a ceremony that lapses without ever being completed is now recorded as `abandoned` instead of being deleted unlabelled. But every abandonment looks identical, so the question operators actually ask cannot be answered:

- a user who decided not to use their passkey and dismissed the prompt in under two seconds, and
- a user who tried Touch ID repeatedly, fell through to the device passcode, and gave up after 20+
seconds

produce exactly the same `abandoned` row. The first is a deliberate opt-out. The second is a UX
failure that should show up as one. Today they are pooled, so `abandonmentRate` cannot distinguish
"users don't want passkeys" from "passkeys aren't working for users".

The browser already knows the difference and throws it away. In
`jans-auth-server/server/src/main/webapp/auth/fido2/passkeys.xhtml`, the `.catch()` on
`webauthn.getAssertion()` (line 81) receives the `DOMException`, reads `err.name` to pick a message,
and then `return false` — the error name is never sent anywhere. No start timestamp is taken either,
so elapsed time is not even measured.

This was raised by @arnab-dutta on a recorded reproduction where the user entered the wrong biometric
several times before cancelling; from the server side that is indistinguishable from an instant cancel.

**Describe the solution you'd like**

Report the client-side outcome of a ceremony and store it on the abandonment record.

1. **Capture it.** In `passkeys.xhtml`, take a timestamp before calling `webauthn.getAssertion()` and,
in the existing `.catch()`, capture `err.name` (`NotAllowedError`, `AbortError`,
`InvalidStateError`, …) and the elapsed milliseconds.
2. **Relay it.** The browser never talks to the FIDO2 server directly — the page submits `fido2_form`
to the Authorization Server, which calls FIDO2 server-side. The report should follow the same path
rather than introducing a browser→FIDO2 call.
3. **Accept it.** A new write endpoint on the FIDO2 metrics API taking `{challenge, errorName,
elapsedMs}`. `Fido2MetricsController` is currently read-only — all endpoints are `@GET`, there is
not a single `@POST` — so this is a new capability, and the challenge is what ties the report back
to a specific ceremony.
4. **Store it.** Record the reported outcome on the ceremony, so an `abandoned` row carries why it was
abandoned, and surface a breakdown alongside `abandonedOperations` / `abandonmentRate`.

Elapsed time is the classifier: rejection under ~2s reads as a deliberate cancel, 15–30s+ reads as
repeated verification failures. It is a heuristic, not ground truth — but it is the only signal any
relying party can obtain, and it separates the two populations well enough to act on.

The report must be treated as untrusted client input: it can only annotate a ceremony that already
exists and is already terminal, never create one, change its status, or affect authentication.

**Describe alternatives you've considered**

- **Post directly from the browser to the FIDO2 server.** Rejected. It needs CORS configuration and
browser-reachable exposure of a service that is internal-only in most deployments. Relaying through
the Authorization Server keeps the existing trust boundary.
- **Infer it server-side from timing.** Not possible. The server sees `/assertion/options` and then
nothing at all; there is no second event whose delay could be measured. The elapsed time only exists
in the browser.
- **Ask the user after a cancel ("Having trouble with Touch ID?").** Gives ground truth rather than a
proxy, but only on a sample, and it adds friction at the exact moment the user is already
struggling. Worth considering later as a complement, not a replacement.
- **Correlate with the fallback event instead.** "Cancelled passkey → immediately used password" is
already derivable now that the cancel is recorded, and is useful — but it says what the user did
next, not why they gave up. A user who cancels deliberately and one who fails biometrics both fall
back to a password.
- **Do nothing.** Rejected: the signal exists, is already in hand in the browser, and is discarded in
a `.catch()` that is three lines from where it could be sent.

**Additional context**
Follows [#14737](https://github.com/JanssenProject/jans/issues/14737), fixed by [#14738](https://github.com/JanssenProject/jans/pull/14738/) (merged) — there was no abandonment record to annotate until that landed.

**Hard limit, which this does not change.** The count of failed biometric attempts is not obtainable
by any relying party. With platform authenticators, user verification happens inside the authenticator:
a wrong fingerprint is retried locally and eventually falls back to the device passcode, and the
authenticator only emits an assertion once verification has already succeeded. The RP is never told how
the user verified or how many times they failed. Elapsed time before the rejection is the closest proxy
available to anyone, which is why this issue asks for it rather than for the attempt count.

Scope note: this spans `jans-auth-server` (the page and the relay) and `jans-fido2` (the endpoint and
storage), which is why it was deliberately kept out of #14738 rather than widening an already
cross-module change.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.