1
0

Add passkeys project: WebAuthn ceremonies, a software authenticator and the one-time-token fallback

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.
This commit is contained in:
2026-08-25 22:54:40 +05:30
parent e9381dc5be
commit f6dd692177
59 changed files with 3567 additions and 4 deletions

View File

@@ -0,0 +1,100 @@
[← 01 — Versions](01-versions.md) · [index](README.md) · next: [03 — The two ceremonies](03-the-two-ceremonies.md)
# The minimum configuration
```java
http
.authorizeHttpRequests((requests) -> requests.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.webAuthn((webAuthn) -> webAuthn
.rpId("localhost")
.rpName("ankurm passkeys demo")
.allowedOrigins("http://localhost:8080"));
```
Plus a `UserDetailsService` bean. Without one,
`WebAuthnConfigurer.configure` throws `IllegalStateException: Missing UserDetailsService Bean`
at startup — passkeys authenticate a *credential*, and Spring still needs somewhere to
look up the authorities that go with the username the credential resolves to.
That is the whole thing. It gives you six endpoints and a working browser flow:
| method | path | what it does |
|---|---|---|
| `POST` | `/webauthn/register/options` | issues a challenge and the creation options; requires an authenticated session |
| `POST` | `/webauthn/register` | verifies the attestation and stores a `CredentialRecord` |
| `DELETE` | `/webauthn/register/{id}` | removes a credential, guarded by `CredentialRecordOwnerAuthorizationManager` |
| `GET` | `/webauthn/register` | the built-in registration page |
| `POST` | `/webauthn/authenticate/options` | issues a challenge and the request options |
| `POST` | `/login/webauthn` | verifies the assertion and creates the session |
## rpId and allowedOrigins are two settings, not one
The **relying party id** is a domain. It is hashed into authenticator data and it scopes the
credential: a passkey created for `example.com` will be offered on `app.example.com`, because
the rpId must equal the origin's effective domain or be a registrable suffix of it.
The **allowed origin** is the exact scheme, host and port string the browser puts in client
data. `http://localhost:8080` and `http://127.0.0.1:8080` are different origins even though
they reach the same server, and `localhost` is not a registrable suffix of `127.0.0.1`. This
is the single most common way to get a flat 401 out of a configuration that looks correct.
`localhost` is special: browsers treat it as a secure context, so WebAuthn works over plain
HTTP there and nowhere else. The first deployment to a real hostname needs TLS before the
ceremony will start at all.
## The bean that silently disables the DSL
```java
private WebAuthnRelyingPartyOperations webAuthnRelyingPartyOperations(
PublicKeyCredentialUserEntityRepository userEntities, UserCredentialRepository userCredentials) {
Optional<WebAuthnRelyingPartyOperations> webauthnOperationsBean = getBeanOrNull(
WebAuthnRelyingPartyOperations.class);
String rpName = (this.rpName != null) ? this.rpName : this.rpId;
return webauthnOperationsBean
.orElseGet(() -> new Webauthn4JRelyingPartyOperations(userEntities, userCredentials,
PublicKeyCredentialRpEntity.builder().id(this.rpId).name(rpName).build(), this.allowedOrigins));
}
```
&mdash; `WebAuthnConfigurer`, Spring Security 7.1.1
If a `WebAuthnRelyingPartyOperations` bean exists, it is used **as is**. The `rpId`, `rpName`
and `allowedOrigins` you set on the DSL are never read. That is not a bug, but it is a
silent one: the configuration still compiles, still starts, and still points at whatever the
bean was constructed with.
[`RelyingPartyConfig`](../../passkeys/src/main/java/com/ankurm/passkeys/config/RelyingPartyConfig.java)
in this module exposes exactly such a bean under three profiles, which is why it repeats the
rpId and origin rather than sharing them with
[`SecurityConfig`](../../passkeys/src/main/java/com/ankurm/passkeys/config/SecurityConfig.java).
## Where the filters land
From [`docs/output/pk-filters.txt`](../output/pk-filters.txt), on the real running chain:
```
5 CsrfFilter
7 GenerateOneTimeTokenFilter
8 UsernamePasswordAuthenticationFilter
9 OneTimeTokenAuthenticationFilter
16 WebAuthnAuthenticationFilter
20 ExceptionTranslationFilter
21 PublicKeyCredentialCreationOptionsFilter
22 PublicKeyCredentialRequestOptionsFilter
23 AuthorizationFilter
24 WebAuthnRegistrationFilter
25 DefaultWebAuthnRegistrationPageGeneratingFilter
```
Note where the line falls. The two options filters sit **before** `AuthorizationFilter`;
`WebAuthnRegistrationFilter` sits **after** it. So `/webauthn/register/options` is answered
without an authorization check, and enforces its own requirement that the caller be
authenticated &mdash; which it does by throwing. See
[06 &mdash; The bootstrap problem](06-the-bootstrap-problem.md).
`CsrfFilter` at position 5 applies to all of them. Every WebAuthn endpoint is a `POST` that
changes server state (it stores a challenge), so every call needs a CSRF token. A front end
that fetches options without one gets a 403 and no explanation.
[&larr; 01 &mdash; Versions](01-versions.md) &middot; [index](README.md) &middot; next: [03 &mdash; The two ceremonies](03-the-two-ceremonies.md)