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.
4.9 KiB
← 01 — Versions · index · next: 03 — The two ceremonies
The minimum configuration
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
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));
}
— 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
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.
Where the filters land
From docs/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 — which it does by throwing. See
06 — The bootstrap problem.
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.
← 01 — Versions · index · next: 03 — The two ceremonies