Fourth Maven project in the repository. Registration and authentication run end to end with no browser and no hardware key: VirtualAuthenticator emits real CBOR attestation objects and real ES256 assertion signatures, and tools/PasskeyCeremony.java drives the live HTTP endpoints with them. Profiles cover userVerification REQUIRED, DIRECT attestation, a disallowed origin and JDBC persistence. Eleven doc chapters and twelve captured transcripts under docs/passkeys and docs/output/pk-*.txt, all regenerated by passkeys/scripts/run-all.sh.
93 lines
5.1 KiB
Markdown
93 lines
5.1 KiB
Markdown
[← 06 — The bootstrap problem](06-the-bootstrap-problem.md) · [index](README.md) · next: [08 — The one-time-token fallback](08-one-time-token-fallback.md)
|
|
|
|
# Failure modes, and what each one looks like
|
|
|
|
Passkey failures are quiet. The authentication endpoint returns a bare 401 with no body, the
|
|
registration endpoint returns a 500 with a generic error page, and the reason is only ever in
|
|
the server log at DEBUG. This chapter is a lookup table.
|
|
|
|
Run with the `trace` profile to see any of it:
|
|
|
|
```bash
|
|
./scripts/run.sh trace
|
|
```
|
|
|
|
## Every registration failure is an HTTP 500
|
|
|
|
This is worth stating on its own, because it is the first thing that confuses people.
|
|
`WebAuthnRegistrationFilter` has no error handling: whatever WebAuthn4J throws propagates out
|
|
of the filter and Boot's error page turns it into a 500. Three different mistakes, three
|
|
identical responses ([`pk-origin.txt`](../output/pk-origin.txt),
|
|
[`pk-user-verification.txt`](../output/pk-user-verification.txt),
|
|
[`pk-duplicate.txt`](../output/pk-duplicate.txt)):
|
|
|
|
```
|
|
$ POST /webauthn/register
|
|
HTTP 500
|
|
{"timestamp":"...","status":500,"error":"Internal Server Error","path":"/webauthn/register"}
|
|
```
|
|
|
|
Authentication is different. `WebAuthnAuthenticationProvider` catches everything:
|
|
|
|
```java
|
|
catch (RuntimeException ex) {
|
|
throw new BadCredentialsException(ex.getMessage(), ex);
|
|
}
|
|
```
|
|
|
|
so the client gets a clean `401` with an empty body, and the cause is discarded before it
|
|
reaches any response the browser can see. Both behaviours mean the same thing: **read the
|
|
server log, not the HTTP response**.
|
|
|
|
## The table
|
|
|
|
| symptom | cause | where to look |
|
|
|---|---|---|
|
|
| `HTTP 400`, empty body, on `/webauthn/register/options` | nobody is logged in, or the session expired | `IllegalArgumentException: Authentication must be authenticated` |
|
|
| `HTTP 403` on any `/webauthn/**` POST | missing CSRF token — all five endpoints are state-changing POSTs | `CsrfFilter` at position 5 |
|
|
| `HTTP 500` on `/webauthn/register` | origin mismatch | `BadOriginException: The collectedClientData origin '...' doesn't match expected: ...` |
|
|
| `HTTP 500` on `/webauthn/register` | UV required, authenticator did not verify | `UserNotVerifiedException: ... UV flag in authenticatorData is not set` |
|
|
| `HTTP 500` on `/webauthn/register` | the credential id is already registered | `IllegalArgumentException: Credential with id ... already exists` |
|
|
| `HTTP 401`, empty body, on `/login/webauthn` | origin mismatch, bad signature, unknown credential id, or a null stored attestation object | `BadCredentialsException` wrapping the real cause |
|
|
| `navigator.credentials.create` never prompts | not a secure context — plain HTTP on anything but `localhost` | browser console, not the server |
|
|
| credentials vanish on restart | `MapUserCredentialRepository`, the default | [09 — Persistence](09-persistence.md) |
|
|
| `NullPointerException` during registration | an authenticator transport Spring does not know, e.g. `cable` | [spring-security#19366](https://github.com/spring-projects/spring-security/issues/19366) |
|
|
| WebAuthn breaks under Spring Session with Redis | creation options serialisation | [spring-security#16328](https://github.com/spring-projects/spring-security/issues/16328) |
|
|
|
|
## The origin mismatch, in full
|
|
|
|
This is the one that matters, because it is the phishing defence working. From
|
|
[`docs/output/pk-origin.txt`](../output/pk-origin.txt), with the client sending
|
|
`http://evil.localhost:8080` and the relying party configured for `http://localhost:8080`:
|
|
|
|
```
|
|
com.webauthn4j.verifier.exception.BadOriginException: The collectedClientData origin
|
|
'http://evil.localhost:8080' doesn't match expected: http://localhost:8080
|
|
at com.webauthn4j.verifier.OriginVerifierImpl.verify(OriginVerifierImpl.java:72)
|
|
at com.webauthn4j.verifier.RegistrationDataVerifier.verify(RegistrationDataVerifier.java:171)
|
|
```
|
|
|
|
and the same mistake one ceremony later:
|
|
|
|
```
|
|
$ POST /login/webauthn (origin http://evil.localhost:8080)
|
|
HTTP 401
|
|
(empty body)
|
|
```
|
|
|
|
In a real browser this failure is not reachable: the browser writes the origin itself and the
|
|
credential is scoped to an rpId, so a phishing page never gets an assertion to send. The
|
|
software authenticator in this module can lie about its origin precisely so the server-side
|
|
half of the check can be watched.
|
|
|
|
## Debugging checklist
|
|
|
|
1. Is the rpId a domain, and does it match or registrably-suffix the origin's host?
|
|
2. Is the allowed origin the exact string, with scheme and port?
|
|
3. Is there a `WebAuthnRelyingPartyOperations` bean quietly overriding the DSL? ([02](02-minimum-configuration.md))
|
|
4. Is the request a POST with a CSRF token?
|
|
5. Is the session the same one that received the challenge? The default `PublicKeyCredentialCreationOptionsRepository` is `HttpSession`-backed, so a load balancer without sticky sessions breaks registration and nothing else.
|
|
6. Turn on `logging.level.com.webauthn4j: DEBUG` and read the actual exception.
|
|
|
|
[← 06 — The bootstrap problem](06-the-bootstrap-problem.md) · [index](README.md) · next: [08 — The one-time-token fallback](08-one-time-token-fallback.md)
|