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:
92
docs/passkeys/01-versions.md
Normal file
92
docs/passkeys/01-versions.md
Normal file
@@ -0,0 +1,92 @@
|
||||
[← index](README.md) · next: [02 — The minimum configuration](02-minimum-configuration.md)
|
||||
|
||||
# Versions, artifacts and the 7.0 split
|
||||
|
||||
## The dependency the tutorials forget
|
||||
|
||||
Passkey support arrived in Spring Security **6.4**, and at that time it lived inside
|
||||
`spring-security-web` — which meant it was already on the classpath of every Spring
|
||||
Boot application that used `spring-boot-starter-security`. As of Spring Security **7.0** it
|
||||
does not. The classes were moved into a new artifact, `spring-security-webauthn`, which
|
||||
`spring-boot-starter-security` does **not** pull in.
|
||||
|
||||
The move is visible in the jars:
|
||||
|
||||
```
|
||||
$ unzip -l spring-security-web-6.5.11.jar | grep -c -i webauthn
|
||||
135
|
||||
|
||||
$ unzip -l spring-security-web-7.1.1.jar | grep -i webauthn
|
||||
10036 org/springframework/security/spring-security-webauthn.js
|
||||
|
||||
$ unzip -l spring-security-webauthn-7.1.1.jar | grep -c -i webauthn
|
||||
139
|
||||
```
|
||||
|
||||
The package names did **not** change — everything is still
|
||||
`org.springframework.security.web.webauthn.*`. That is what makes the upgrade awkward: your
|
||||
imports keep compiling against a stale local repository and fail on a clean build, and the
|
||||
DSL method `http.webAuthn(..)` lives in `spring-security-config`, which is present either
|
||||
way. Nothing tells you what is wrong except a `ClassNotFoundException` or a missing method.
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-webauthn</artifactId>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
No version: `spring-boot-dependencies:4.1.1` manages it, at 7.1.1.
|
||||
|
||||
## What did not move
|
||||
|
||||
Two things were left behind in `spring-security-web`:
|
||||
|
||||
| artefact | where it lives | why it matters |
|
||||
|---|---|---|
|
||||
| `spring-security-webauthn.js` | `spring-security-web` | the browser-side script the default pages load |
|
||||
| `user-credentials-schema.sql` | `spring-security-web` | the DDL for `JdbcUserCredentialRepository` |
|
||||
| `user-entities-schema.sql` | `spring-security-web` | the DDL for `JdbcPublicKeyCredentialUserEntityRepository` |
|
||||
|
||||
`spring-security-webauthn` depends on `spring-security-web`, so all three are still reachable
|
||||
— but a search inside the webauthn jar for the schema files the Javadoc points at comes
|
||||
up empty, which is confusing the first time. See [09 — Persistence](09-persistence.md).
|
||||
|
||||
## The versions this module was built and run against
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| JDK | Temurin **25.0.4.1+1** (current LTS) |
|
||||
| Spring Boot | **4.1.1** |
|
||||
| Spring Framework | **7.0.9** |
|
||||
| Spring Security | **7.1.1** (GA 20 August 2026) |
|
||||
| `spring-security-webauthn` | **7.1.1** |
|
||||
| WebAuthn4J | **0.31.9.RELEASE** |
|
||||
| Jackson | **3.1.5** (`tools.jackson`) |
|
||||
| Tomcat | **11.0.24** |
|
||||
| H2 | **2.4.240** (jdbc profile only) |
|
||||
| Maven | 3.9.11 |
|
||||
|
||||
Two details behind that table are worth keeping:
|
||||
|
||||
**WebAuthn4J 0.31.9 is a Jackson 3 library.** Its POM declares
|
||||
`tools.jackson.core:jackson-databind:3.2.1` and
|
||||
`tools.jackson.dataformat:jackson-dataformat-cbor:3.2.1`. Spring Boot 4.1.1 manages Jackson at
|
||||
3.1.5 and wins, so what actually resolves is `jackson-dataformat-cbor-3.1.5.jar`. Everything
|
||||
in this module ran on that combination. If you are still on a Jackson 2 application, this is
|
||||
a real constraint rather than a footnote.
|
||||
|
||||
**`spring-security-webauthn` 7.0.7 and 7.1.1 contain exactly the same set of classes.** A
|
||||
class-by-class diff of the two jars is empty; only the pinned WebAuthn4J version moved, from
|
||||
0.31.6 to 0.31.9. Nothing in this article is 7.1-specific in the way that, say,
|
||||
`csrf.spa()` was 7.0-specific — it applies to the whole 7.x line, and mostly to 6.4 and
|
||||
6.5 as well once you account for the artifact split.
|
||||
|
||||
## What Spring Boot does not do
|
||||
|
||||
There is no WebAuthn auto-configuration, and there are no `spring.security.webauthn.*`
|
||||
properties. Grepping `spring-boot-autoconfigure-4.1.1.jar` and `spring-boot-security-4.1.1.jar`
|
||||
for `webauthn` returns nothing. Every relying party setting in this module is Java
|
||||
configuration, because Java configuration is the only option.
|
||||
|
||||
[← index](README.md) · next: [02 — The minimum configuration](02-minimum-configuration.md)
|
||||
100
docs/passkeys/02-minimum-configuration.md
Normal file
100
docs/passkeys/02-minimum-configuration.md
Normal 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));
|
||||
}
|
||||
```
|
||||
|
||||
— `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 — which it does by throwing. See
|
||||
[06 — 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.
|
||||
|
||||
[← 01 — Versions](01-versions.md) · [index](README.md) · next: [03 — The two ceremonies](03-the-two-ceremonies.md)
|
||||
97
docs/passkeys/03-the-two-ceremonies.md
Normal file
97
docs/passkeys/03-the-two-ceremonies.md
Normal file
@@ -0,0 +1,97 @@
|
||||
[← 02 — The minimum configuration](02-minimum-configuration.md) · [index](README.md) · next: [04 — A software authenticator](04-virtual-authenticator.md)
|
||||
|
||||
# The two ceremonies
|
||||
|
||||
WebAuthn has exactly two flows, and both have the same shape: the server issues a challenge,
|
||||
the authenticator answers it, the server verifies the answer. Everything else is detail.
|
||||
|
||||
## Registration
|
||||
|
||||
```
|
||||
POST /webauthn/register/options (authenticated session + CSRF token required)
|
||||
-> challenge, rp, user, pubKeyCredParams, authenticatorSelection, attestation, timeout
|
||||
and the challenge is stored in the HttpSession
|
||||
|
||||
navigator.credentials.create({ publicKey: options })
|
||||
-> a key pair inside the authenticator, and an attestation object containing the public half
|
||||
|
||||
POST /webauthn/register { publicKey: { credential: {...}, label: "..." } }
|
||||
-> { "success": true }, and a CredentialRecord in the UserCredentialRepository
|
||||
```
|
||||
|
||||
The real options object, from a live run
|
||||
([`docs/output/pk-ceremony.txt`](../output/pk-ceremony.txt)):
|
||||
|
||||
```json
|
||||
{"attestation":"none","authenticatorSelection":{"residentKey":"required","userVerification":"preferred"},
|
||||
"challenge":"8H0qrJXIL_StIdOetvSm31hEcEy0NMoN8xz67u4UwFM","excludeCredentials":[],
|
||||
"extensions":{"credProps":true},
|
||||
"pubKeyCredParams":[{"alg":-8,"type":"public-key"},{"alg":-7,"type":"public-key"},{"alg":-257,"type":"public-key"}],
|
||||
"rp":{"id":"localhost","name":"ankurm passkeys demo"},"timeout":300000,
|
||||
"user":{"name":"user","id":"9RZ4HDuLE38GIFoTpth0hyAooU7i1HV49qJ46isEtAs","displayName":"user"}}
|
||||
```
|
||||
|
||||
Everything in there is a Spring Security default, and every one of them is a decision:
|
||||
|
||||
| field | default | what it means |
|
||||
|---|---|---|
|
||||
| `attestation` | `none` | do not ask the authenticator to identify its make and model |
|
||||
| `residentKey` | `required` | a discoverable credential, so the user need not type a username |
|
||||
| `userVerification` | `preferred` | check a PIN or biometric **if convenient** — see [05](05-defaults.md) |
|
||||
| `pubKeyCredParams` | EdDSA, ES256, RS256 | in that order of preference |
|
||||
| `timeout` | 300000 ms | five minutes to complete the ceremony |
|
||||
| `extensions` | `credProps` | ask the browser whether the credential ended up discoverable |
|
||||
| `excludeCredentials` | this user's existing credentials | so the same authenticator is not enrolled twice |
|
||||
|
||||
`user.id` is a random 32-byte handle generated by
|
||||
`Webauthn4JRelyingPartyOperations.findUserEntityOrCreateAndSave` on first use. It is **not**
|
||||
the username, and it must not be: it is stored in the authenticator, it syncs to the user's
|
||||
other devices, and it is visible to anything that can talk to the authenticator.
|
||||
|
||||
## Authentication
|
||||
|
||||
```
|
||||
POST /webauthn/authenticate/options (CSRF token required; no session needed)
|
||||
-> challenge, rpId, allowCredentials, userVerification, timeout
|
||||
|
||||
navigator.credentials.get({ publicKey: options })
|
||||
-> authenticatorData, clientDataJSON, an ECDSA signature, and a userHandle
|
||||
|
||||
POST /login/webauthn { id, rawId, response: {...}, type: "public-key" }
|
||||
-> { "authenticated": true, "redirectUrl": "/" } or a bare 401
|
||||
```
|
||||
|
||||
```json
|
||||
{"allowCredentials":[],"challenge":"18HVET3lRrveXvRdKS1K5k2Ji_3nqm1Krx_l3ZOB0wU","extensions":{},
|
||||
"rpId":"localhost","timeout":300000,"userVerification":"preferred"}
|
||||
```
|
||||
|
||||
`allowCredentials` is empty because the caller is anonymous:
|
||||
`Webauthn4JRelyingPartyOperations.findCredentialRecords` returns an empty list when there is
|
||||
no authenticated user, and the browser falls back to offering whatever discoverable
|
||||
credentials it holds for that rpId. That empty array is the *usernameless* login most people
|
||||
mean when they say “passkey”, and it only works because `residentKey` defaulted to
|
||||
`required` during registration. The two settings are one decision made in two places.
|
||||
|
||||
## What the signature actually covers
|
||||
|
||||
```
|
||||
signature = ECDSA-SHA256( authenticatorData || SHA-256(clientDataJSON) )
|
||||
```
|
||||
|
||||
`authenticatorData` is `rpIdHash(32) || flags(1) || signCount(4)`, with attested credential
|
||||
data appended during registration and omitted during assertion. `clientDataJSON` carries the
|
||||
type, the challenge and the **origin** — and it is the browser, not the page, that fills
|
||||
the origin in. That is the entire phishing defence: a credential minted for `bank.example` is
|
||||
never offered to `bank-example.evil`, and even if it were, the origin in client data would not
|
||||
match and the relying party would refuse. [07 — Failure modes](07-failure-modes.md)
|
||||
shows that refusal happening.
|
||||
|
||||
## The bit that surprises people
|
||||
|
||||
Nothing above involves a password, and nothing above involves a username either — but
|
||||
the registration ceremony required an authenticated session to even start. The credential is
|
||||
bound to a user who was already identified some other way. See
|
||||
[06 — The bootstrap problem](06-the-bootstrap-problem.md).
|
||||
|
||||
[← 02 — The minimum configuration](02-minimum-configuration.md) · [index](README.md) · next: [04 — A software authenticator](04-virtual-authenticator.md)
|
||||
96
docs/passkeys/04-virtual-authenticator.md
Normal file
96
docs/passkeys/04-virtual-authenticator.md
Normal file
@@ -0,0 +1,96 @@
|
||||
[← 03 — The two ceremonies](03-the-two-ceremonies.md) · [index](README.md) · next: [05 — The defaults](05-defaults.md)
|
||||
|
||||
# A software authenticator, so the ceremonies can be executed
|
||||
|
||||
Every claim in these chapters comes from a run, which means something had to play the part of
|
||||
the security key. Chrome's DevTools virtual authenticator can do it interactively; it cannot
|
||||
be scripted into `run-all.sh`, and it cannot be checked into a repository.
|
||||
|
||||
[`VirtualAuthenticator`](../../passkeys/src/main/java/com/ankurm/passkeys/virtual/VirtualAuthenticator.java)
|
||||
is about two hundred lines and does the whole job. Spring Security and WebAuthn4J verify its
|
||||
output without knowing that no hardware was involved — which is itself worth noticing.
|
||||
|
||||
## What it has to produce
|
||||
|
||||
**Authenticator data**, a fixed binary layout:
|
||||
|
||||
```
|
||||
rpIdHash 32 bytes SHA-256 of the rpId string, not of the origin
|
||||
flags 1 byte UP 0x01, UV 0x04, BE 0x08, BS 0x10, AT 0x40
|
||||
signCount 4 bytes big endian
|
||||
attestedCredData variable registration only: aaguid(16) || credIdLen(2) || credId || COSE key
|
||||
```
|
||||
|
||||
**A COSE public key**, CBOR, for ES256:
|
||||
|
||||
```java
|
||||
new Cbor().map(5)
|
||||
.num(1).num(2) // kty: EC2
|
||||
.num(3).num(-7) // alg: ES256
|
||||
.num(-1).num(1) // crv: P-256
|
||||
.num(-2).bytes(x) // 32 bytes, left-padded
|
||||
.num(-3).bytes(y) // 32 bytes, left-padded
|
||||
.toByteArray();
|
||||
```
|
||||
|
||||
The left-padding matters. `BigInteger.toByteArray()` returns a two's-complement encoding: it
|
||||
prepends a zero byte when the top bit is set, and it drops leading zero bytes when they are
|
||||
not. Either way you get 31 or 33 bytes roughly half the time, and the relying party rejects
|
||||
the key without telling you why.
|
||||
|
||||
**An attestation object**, also CBOR:
|
||||
|
||||
```java
|
||||
new Cbor().map(3)
|
||||
.text("fmt").text("none")
|
||||
.text("attStmt").map(0)
|
||||
.text("authData").bytes(authData)
|
||||
.toByteArray();
|
||||
```
|
||||
|
||||
178 bytes, in this module's case. [`Cbor`](../../passkeys/src/main/java/com/ankurm/passkeys/virtual/Cbor.java)
|
||||
is a forty-line encoder covering the four CBOR major types WebAuthn needs; pulling in a
|
||||
library for this would hide the structure rather than explain it.
|
||||
|
||||
**A signature** over `authenticatorData || SHA-256(clientDataJSON)`, using
|
||||
`SHA256withECDSA`, which emits the DER encoding WebAuthn expects.
|
||||
|
||||
## What it deliberately does not do
|
||||
|
||||
It sets the UP and UV flags because it was asked to, not because anything happened. There is
|
||||
no user presence test, no biometric, no secure element, and the private key sits in the heap
|
||||
next to everything else.
|
||||
|
||||
That is not a shortcoming, it is the point. A relying party cannot tell the difference between
|
||||
this and a real authenticator unless it verifies attestation — and by default Spring
|
||||
Security does not. See [05 — The defaults](05-defaults.md).
|
||||
|
||||
## Driving it
|
||||
|
||||
[`tools/PasskeyCeremony.java`](../../passkeys/tools/PasskeyCeremony.java) is a single-file
|
||||
source program (JEP 458, so it compiles its dependencies from the same directory on the fly).
|
||||
It uses `java.net.http.HttpClient` with a real cookie jar and drives the actual HTTP endpoints,
|
||||
CSRF tokens and all — not the operations bean directly — so filter ordering,
|
||||
session handling and response codes are exercised too.
|
||||
|
||||
```bash
|
||||
java --class-path "target/classes:$(cat target/deps.txt)" tools/PasskeyCeremony.java register-and-login
|
||||
```
|
||||
|
||||
| scenario | what it shows |
|
||||
|---|---|
|
||||
| `register-and-login` | both ceremonies end to end |
|
||||
| `clone-counter` | the signature counter, and what is done with it |
|
||||
| `no-uv` | a credential whose UV flag was never set |
|
||||
| `wrong-origin` / `wrong-origin-login` | a phishing attempt, in each ceremony |
|
||||
| `duplicate` | `excludeCredentials`, and a client that ignores it |
|
||||
| `bootstrap` | asking for registration options with nobody logged in |
|
||||
| `ott` | the one-time-token fallback |
|
||||
| `filters` | the live filter chain |
|
||||
|
||||
The [contract tests](../../passkeys/src/test/java/com/ankurm/passkeys/PasskeyContractTests.java)
|
||||
use the same authenticator against `Webauthn4JRelyingPartyOperations` directly, with no HTTP
|
||||
and no Spring context, so a failure there is the framework's behaviour rather than a filter
|
||||
accident.
|
||||
|
||||
[← 03 — The two ceremonies](03-the-two-ceremonies.md) · [index](README.md) · next: [05 — The defaults](05-defaults.md)
|
||||
126
docs/passkeys/05-defaults.md
Normal file
126
docs/passkeys/05-defaults.md
Normal file
@@ -0,0 +1,126 @@
|
||||
[← 04 — A software authenticator](04-virtual-authenticator.md) · [index](README.md) · next: [06 — The bootstrap problem](06-the-bootstrap-problem.md)
|
||||
|
||||
# The defaults, and what they do not check
|
||||
|
||||
Spring Security's passkey defaults are reasonable. They are also weaker than most people
|
||||
assume, in ways that do not show up until you go looking.
|
||||
|
||||
## userVerification is `preferred`, which means optional
|
||||
|
||||
`Webauthn4JRelyingPartyOperations.createPublicKeyCredentialCreationOptions` builds:
|
||||
|
||||
```java
|
||||
AuthenticatorSelectionCriteria.builder()
|
||||
.userVerification(UserVerificationRequirement.PREFERRED)
|
||||
.residentKey(ResidentKeyRequirement.REQUIRED)
|
||||
.build();
|
||||
```
|
||||
|
||||
and both `registerCredential` and `authenticate` decide whether to enforce UV like this:
|
||||
|
||||
```java
|
||||
boolean userVerificationRequired = UserVerificationRequirement.REQUIRED
|
||||
.equals(creationOptions.getAuthenticatorSelection().getUserVerification());
|
||||
```
|
||||
|
||||
`PREFERRED` is not `REQUIRED`, so the flag is not checked. An authenticator that answers with
|
||||
UV clear registers successfully and logs in successfully. From
|
||||
[`docs/output/pk-user-verification.txt`](../output/pk-user-verification.txt):
|
||||
|
||||
```
|
||||
$ POST /webauthn/register
|
||||
HTTP 200
|
||||
{"success":true}
|
||||
|
||||
$ GET /diag/credentials (note uvInitialized)
|
||||
{"credentials":[{"label":"no-uv", ... "signatureCount":0,"uvInitialized":false, ...}]}
|
||||
|
||||
$ POST /login/webauthn
|
||||
HTTP 200
|
||||
{"authenticated":true,"redirectUrl":"/"}
|
||||
```
|
||||
|
||||
`uvInitialized: false` is recorded faithfully — and then nothing consults it. What you
|
||||
have is a single-factor credential: possession of the authenticator, with no proof that the
|
||||
person holding it is the enrolled user. For most consumer sites that is an acceptable trade
|
||||
(it is roughly what a password gives you, minus the phishing). For anything where the passkey
|
||||
*is* the second factor, it is not, and the fix is one setting on both ceremonies:
|
||||
|
||||
```java
|
||||
operations.setCustomizeCreationOptions((options) -> options.authenticatorSelection(
|
||||
AuthenticatorSelectionCriteria.builder()
|
||||
.userVerification(UserVerificationRequirement.REQUIRED)
|
||||
.residentKey(ResidentKeyRequirement.REQUIRED)
|
||||
.build()));
|
||||
operations.setCustomizeRequestOptions((options) ->
|
||||
options.userVerification(UserVerificationRequirement.REQUIRED));
|
||||
```
|
||||
|
||||
Setting only the first leaves logins unverified forever, which is the worst of the three
|
||||
outcomes because registration looks correct. The `uvrequired` profile in this module sets
|
||||
both; with it, the same authenticator is refused:
|
||||
|
||||
```
|
||||
com.webauthn4j.verifier.exception.UserNotVerifiedException:
|
||||
Verifier is configured to check user verified, but UV flag in authenticatorData is not set.
|
||||
```
|
||||
|
||||
`AuthenticatorSelectionCriteria.builder()` has no copy constructor, so overriding
|
||||
`userVerification` means restating `residentKey` too. Forget it and you silently drop back to
|
||||
a non-discoverable credential, and usernameless login stops working.
|
||||
|
||||
## Asking for attestation is not verifying attestation
|
||||
|
||||
The default is `AttestationConveyancePreference.NONE`. Switch it to `DIRECT` and the options
|
||||
change:
|
||||
|
||||
```json
|
||||
{"attestation":"direct","authenticatorSelection":{"residentKey":"required","userVerification":"preferred"}, ...}
|
||||
```
|
||||
|
||||
Then register with an authenticator that answers `fmt: "none"` and an all-zero AAGUID. From
|
||||
[`docs/output/pk-attestation.txt`](../output/pk-attestation.txt):
|
||||
|
||||
```
|
||||
$ POST /webauthn/register
|
||||
HTTP 200
|
||||
{"success":true}
|
||||
```
|
||||
|
||||
The reason is the manager. `Webauthn4JRelyingPartyOperations` initialises with:
|
||||
|
||||
```java
|
||||
private WebAuthnManager webAuthnManager = WebAuthnManager.createNonStrictWebAuthnManager();
|
||||
```
|
||||
|
||||
and that factory installs `NullFIDOU2FAttestationStatementVerifier`,
|
||||
`NullPackedAttestationStatementVerifier`, `NullTPMAttestationStatementVerifier`,
|
||||
`NullAndroidKeyAttestationStatementVerifier`, `NullAndroidSafetyNetAttestationStatementVerifier`,
|
||||
`NullAppleAnonymousAttestationStatementVerifier`, a `NullCertPathTrustworthinessVerifier` and a
|
||||
`NullSelfAttestationTrustworthinessVerifier`. Everything that could check an attestation
|
||||
statement is a null object.
|
||||
|
||||
WebAuthn4J 0.31.9 does not ship a strict counterpart. `WebAuthnManager` has exactly two static
|
||||
factories, `createNonStrictWebAuthnManager()` and its `ObjectConverter` overload; a strict
|
||||
manager has to be assembled from real verifiers and a `TrustAnchorRepository` by hand, and
|
||||
then `setWebAuthnManager` on the operations bean. That is a real project, and it is only worth
|
||||
starting if you have an enterprise reason to care which model of key your users hold. For a
|
||||
consumer product, attestation is usually the wrong thing to spend effort on — but you
|
||||
should know that the `direct` in your options object is currently decorative.
|
||||
|
||||
## What *is* checked, by default, and correctly
|
||||
|
||||
Not everything is a caveat. Out of the box, all of these hold:
|
||||
|
||||
- **Origin.** A mismatch is rejected in both ceremonies. This is the phishing defence and it works.
|
||||
- **Challenge.** Persisted in the `HttpSession` between the options call and the ceremony call, so a replayed or fabricated challenge fails.
|
||||
- **rpId hash.** Compared against the configured relying party.
|
||||
- **Signature.** Verified against the stored COSE key, with the algorithm the key declares.
|
||||
- **User presence.** `userPresenceRequired` is hard-coded `true` at registration, per the specification.
|
||||
- **Duplicate credential ids.** `registerCredential` rejects an id that already exists.
|
||||
|
||||
The unchecked list is short: user verification unless you ask for it, attestation unless you
|
||||
build for it, and the signature counter — which gets its own chapter,
|
||||
[10 — The signature counter](10-signature-counter.md).
|
||||
|
||||
[← 04 — A software authenticator](04-virtual-authenticator.md) · [index](README.md) · next: [06 — The bootstrap problem](06-the-bootstrap-problem.md)
|
||||
106
docs/passkeys/06-the-bootstrap-problem.md
Normal file
106
docs/passkeys/06-the-bootstrap-problem.md
Normal file
@@ -0,0 +1,106 @@
|
||||
[← 05 — The defaults](05-defaults.md) · [index](README.md) · next: [07 — Failure modes](07-failure-modes.md)
|
||||
|
||||
# The bootstrap problem
|
||||
|
||||
A passkey cannot be a user's first credential. This is not a Spring Security limitation, it is
|
||||
the shape of the protocol, and it is the thing that turns “add passkeys” from an
|
||||
afternoon into a project.
|
||||
|
||||
## Where it is enforced
|
||||
|
||||
```java
|
||||
Authentication authentication = request.getAuthentication();
|
||||
if (!this.trustResolver.isAuthenticated(authentication)) {
|
||||
throw new IllegalArgumentException("Authentication must be authenticated");
|
||||
}
|
||||
```
|
||||
|
||||
— `Webauthn4JRelyingPartyOperations.createPublicKeyCredentialCreationOptions`
|
||||
|
||||
`AuthenticationTrustResolverImpl.isAuthenticated` returns false for null and for anonymous, so
|
||||
registration options are only issued to a session that already proved who it belongs to. And
|
||||
they have to be: the options object contains `user.id` and `user.name`, and the credential is
|
||||
bound to them. There is nobody to bind to until somebody has logged in.
|
||||
|
||||
From [`docs/output/pk-bootstrap.txt`](../output/pk-bootstrap.txt):
|
||||
|
||||
```
|
||||
$ POST /webauthn/register/options (anonymous)
|
||||
HTTP 400
|
||||
(empty body)
|
||||
```
|
||||
|
||||
Note the status. Not 401, not a redirect to the login page — a bare **400** with no
|
||||
body, because `PublicKeyCredentialCreationOptionsFilter` sits before `AuthorizationFilter` and
|
||||
the `IllegalArgumentException` surfaces as a bad request. A single-page front end whose session
|
||||
has expired will see a 400 from an endpoint that worked a minute ago, and nothing about that
|
||||
response says “log in again”. Handle it explicitly on the client.
|
||||
|
||||
## So what carries the user in?
|
||||
|
||||
Something else always does. The realistic options, in rough order of how often they are the
|
||||
right answer:
|
||||
|
||||
| route | good for | cost |
|
||||
|---|---|---|
|
||||
| **One-time token** by email or SMS | new signups, and recovery after a lost device | you own a delivery channel and its failure modes |
|
||||
| **Existing password** | adding passkeys to an application that already has users | the password stays, so the phishing surface stays |
|
||||
| **Federated login** (OIDC, social) | consumer products that already federate | the identity provider becomes your recovery story |
|
||||
| **Support desk** | enterprises with an existing identity-proofing process | expensive, and now the desk is the attack surface |
|
||||
|
||||
This module uses the first, because Spring Security ships it: `oneTimeTokenLogin` is one line
|
||||
of DSL, and it covers both the way in and the way back. See
|
||||
[08 — The one-time-token fallback](08-one-time-token-fallback.md).
|
||||
|
||||
## The recovery half is the hard half
|
||||
|
||||
Registration is a solved problem the moment the user is logged in. Recovery is not, and the
|
||||
awkward truth is that **your recovery path sets your real security level**. A passkey that
|
||||
cannot be phished, backed by an email magic link that can be, is an application whose security
|
||||
is that of the email account.
|
||||
|
||||
The mitigations are all trade-offs, and none of them are free:
|
||||
|
||||
- **Require two passkeys.** A phone and a laptop, or a phone and a hardware key. Excellent, and roughly half of users will not do it.
|
||||
- **Rate-limit and age-limit recovery.** A magic link that logs you in but will not register a new passkey for 24 hours turns an account takeover into a race you can notice.
|
||||
- **Notify on registration.** Any new credential produces a mail to every address on file. Cheap, and it is how takeovers actually get caught.
|
||||
- **Step up for sensitive actions.** Recovery gets you in; it does not get you a password change or a payout.
|
||||
|
||||
Spring Security gives you the primitives for the last two: `AuthenticationSuccessHandler` for
|
||||
notification, and `FactorGrantedAuthority` for step-up. A session established by one-time token
|
||||
carries `FACTOR_OTT`; one established by a passkey carries `FACTOR_WEBAUTHN`. From
|
||||
[`docs/output/pk-ott.txt`](../output/pk-ott.txt) and
|
||||
[`docs/output/pk-ceremony.txt`](../output/pk-ceremony.txt):
|
||||
|
||||
```json
|
||||
{"name":"user","authenticationType":"OneTimeTokenAuthentication",
|
||||
"authorities":["FactorGrantedAuthority [authority=FACTOR_OTT, issuedAt=...]","ROLE_USER"]}
|
||||
|
||||
{"name":"user","authenticationType":"WebAuthnAuthentication",
|
||||
"authorities":["FactorGrantedAuthority [authority=FACTOR_WEBAUTHN, issuedAt=...]","ROLE_USER"]}
|
||||
```
|
||||
|
||||
Those are ordinary authorities, so `hasAuthority("FACTOR_WEBAUTHN")` is all it takes to
|
||||
require a real passkey on an endpoint that a magic link should not reach:
|
||||
|
||||
```java
|
||||
.requestMatchers("/passkey-only").hasAuthority("FACTOR_WEBAUTHN")
|
||||
```
|
||||
|
||||
What happens when the authority is missing is better than a 403. `WebAuthnConfigurer.init`
|
||||
registers `defaultDeniedHandlerForMissingAuthority(.., FactorGrantedAuthority.WEBAUTHN_AUTHORITY)`
|
||||
with a `LoginUrlAuthenticationEntryPoint("/login")`, so Spring Security turns "you are
|
||||
authenticated but not with a passkey" into a redirect that says which factor is missing. From
|
||||
[`docs/output/pk-step-up.txt`](../output/pk-step-up.txt), the same user in three sessions:
|
||||
|
||||
```
|
||||
password session -> GET /passkey-only: HTTP 302, Location: /login?factor.type=webauthn&factor.reason=missing
|
||||
one-time-token session -> GET /passkey-only: HTTP 302, Location: /login?factor.type=webauthn&factor.reason=missing
|
||||
passkey session -> GET /passkey-only: HTTP 200 {"ok":"this endpoint required FACTOR_WEBAUTHN"}
|
||||
```
|
||||
|
||||
Those query parameters are generated for you and are not mentioned on the passkeys reference
|
||||
page. A login page that reads `factor.reason=missing` can say “use your passkey for
|
||||
this” instead of “access denied”.
|
||||
|
||||
[← 05 — The defaults](05-defaults.md) · [index](README.md) · next: [07 — Failure modes](07-failure-modes.md)
|
||||
92
docs/passkeys/07-failure-modes.md
Normal file
92
docs/passkeys/07-failure-modes.md
Normal file
@@ -0,0 +1,92 @@
|
||||
[← 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)
|
||||
99
docs/passkeys/08-one-time-token-fallback.md
Normal file
99
docs/passkeys/08-one-time-token-fallback.md
Normal file
@@ -0,0 +1,99 @@
|
||||
[← 07 — Failure modes](07-failure-modes.md) · [index](README.md) · next: [09 — Persistence](09-persistence.md)
|
||||
|
||||
# The one-time-token fallback
|
||||
|
||||
`oneTimeTokenLogin` is Spring Security's magic link. It exists independently of passkeys, but
|
||||
it is the natural partner: it solves the bootstrap problem in [06](06-the-bootstrap-problem.md)
|
||||
and it is the recovery path when a device is lost.
|
||||
|
||||
```java
|
||||
.oneTimeTokenLogin((ott) -> ott.tokenGenerationSuccessHandler(handler))
|
||||
```
|
||||
|
||||
## It will not start without a delivery handler
|
||||
|
||||
There is no default `OneTimeTokenGenerationSuccessHandler`, and there cannot be a sensible one
|
||||
— Spring has no idea whether you send email, SMS or a push. Omit the bean and the
|
||||
context fails to start. That is the correct decision and it is also the first error everybody
|
||||
hits.
|
||||
|
||||
The handler in this module prints the link and writes the token to a file so the demo scripts
|
||||
can read it:
|
||||
|
||||
```java
|
||||
System.out.printf("[one-time-token] username=%s expires=%s%n[one-time-token] %s%n",
|
||||
oneTimeToken.getUsername(), oneTimeToken.getExpiresAt(), link);
|
||||
Files.writeString(TOKEN_FILE, oneTimeToken.getTokenValue(), StandardCharsets.UTF_8);
|
||||
this.redirect.handle(request, response, oneTimeToken);
|
||||
```
|
||||
|
||||
The `redirect.handle(..)` at the end is not optional: the handler owns the HTTP response, so
|
||||
if it does not write one the browser gets a blank page.
|
||||
`RedirectOneTimeTokenGenerationSuccessHandler("/login/ott")` sends the user to the built-in
|
||||
submit page.
|
||||
|
||||
## The flow, and the endpoints
|
||||
|
||||
| method | path | what it does |
|
||||
|---|---|---|
|
||||
| `POST` | `/ott/generate` | takes `username`, generates a token, calls your handler |
|
||||
| `GET` | `/login/ott` | the built-in submit page, prefilled if `?token=` is present |
|
||||
| `POST` | `/login/ott` | redeems the token and creates the session |
|
||||
|
||||
From [`docs/output/pk-ott.txt`](../output/pk-ott.txt):
|
||||
|
||||
```
|
||||
POST /ott/generate -> HTTP 302, Location: http://localhost:8080/login/ott
|
||||
token delivered out of band (the handler wrote it to a file): 3203202f-7e17-4f8b-a613-0d64831c3530
|
||||
POST /login/ott -> HTTP 302, Location: http://localhost:8080/
|
||||
|
||||
$ GET /me
|
||||
{"name":"user","authenticationType":"OneTimeTokenAuthentication",
|
||||
"authorities":["FactorGrantedAuthority [authority=FACTOR_OTT, issuedAt=...]","ROLE_USER"]}
|
||||
```
|
||||
|
||||
Single use is enforced — `OneTimeTokenService.consume` removes it:
|
||||
|
||||
```
|
||||
=== The same token, a second time ===
|
||||
POST /login/ott -> HTTP 302, Location: http://localhost:8080/login?error
|
||||
```
|
||||
|
||||
## It does not tell an attacker whether the account exists
|
||||
|
||||
`InMemoryOneTimeTokenService.generate` takes a `GenerateOneTimeTokenRequest` carrying a
|
||||
username and nothing else. There is no `UserDetailsService` involved, so it cannot check
|
||||
whether the user exists, and it does not try. From
|
||||
[`docs/output/pk-bootstrap.txt`](../output/pk-bootstrap.txt), for a username that is not in
|
||||
the `UserDetailsService`:
|
||||
|
||||
```
|
||||
POST /ott/generate -> HTTP 302, Location: http://localhost:8080/login/ott
|
||||
a token was still generated and delivered: f9f58b17-f2ee-498c-86fe-da3570f9108e
|
||||
POST /login/ott -> HTTP 302, Location: http://localhost:8080/login?error (the failure lands here instead)
|
||||
```
|
||||
|
||||
Byte-identical to a real username, so there is no account-enumeration oracle at the generate
|
||||
endpoint. The failure happens at redemption, in `OneTimeTokenAuthenticationProvider`, where
|
||||
nobody is listening.
|
||||
|
||||
This falls out of the design rather than being aimed at, and it is currently an open question
|
||||
in the project — [spring-security#16483](https://github.com/spring-projects/spring-security/issues/16483)
|
||||
argues that a token should not be created for a user who does not exist. If that changes, check
|
||||
that the response for an unknown username still matches the response for a known one, because
|
||||
it is very easy to fix the storage waste and open an enumeration hole in the same commit.
|
||||
|
||||
## Defaults worth changing
|
||||
|
||||
| setting | default | why you might move it |
|
||||
|---|---|---|
|
||||
| token TTL | 5 minutes (`GenerateOneTimeTokenRequest`) | shorter for recovery, since the mail arrives in seconds |
|
||||
| `OneTimeTokenService` | `InMemoryOneTimeTokenService` | `JdbcOneTimeTokenService` for more than one instance; the DDL is `org/springframework/security/core/ott/jdbc/one-time-tokens-schema.sql` |
|
||||
| token value | a UUID | `GenerateOneTimeTokenRequestResolver` if you need a short numeric code for SMS |
|
||||
| rate limiting | none | there is none. `/ott/generate` will happily mail somebody a hundred links |
|
||||
|
||||
That last row is the one to act on. A magic-link endpoint with no rate limit is a mail bomb
|
||||
and a nuisance-denial-of-service against your own sending reputation. Spring Security does not
|
||||
ship a limiter; put one in front.
|
||||
|
||||
[← 07 — Failure modes](07-failure-modes.md) · [index](README.md) · next: [09 — Persistence](09-persistence.md)
|
||||
93
docs/passkeys/09-persistence.md
Normal file
93
docs/passkeys/09-persistence.md
Normal file
@@ -0,0 +1,93 @@
|
||||
[← 08 — The one-time-token fallback](08-one-time-token-fallback.md) · [index](README.md) · next: [10 — The signature counter](10-signature-counter.md)
|
||||
|
||||
# Persistence
|
||||
|
||||
Two repositories, and the default for both is a `HashMap`.
|
||||
|
||||
| interface | default | JDBC implementation |
|
||||
|---|---|---|
|
||||
| `PublicKeyCredentialUserEntityRepository` | `MapPublicKeyCredentialUserEntityRepository` | `JdbcPublicKeyCredentialUserEntityRepository` |
|
||||
| `UserCredentialRepository` | `MapUserCredentialRepository` | `JdbcUserCredentialRepository` |
|
||||
|
||||
`WebAuthnConfigurer.configure` constructs the map-backed ones if no beans exist, without
|
||||
logging anything. In a passwordless application that is a trap with teeth: restart the process
|
||||
and every user's only credential is gone, and by design they have no password to fall back on.
|
||||
|
||||
## Switching to JDBC
|
||||
|
||||
```java
|
||||
@Bean
|
||||
PublicKeyCredentialUserEntityRepository userEntityRepository(JdbcOperations jdbc) {
|
||||
return new JdbcPublicKeyCredentialUserEntityRepository(jdbc);
|
||||
}
|
||||
|
||||
@Bean
|
||||
UserCredentialRepository userCredentialRepository(JdbcOperations jdbc) {
|
||||
return new JdbcUserCredentialRepository(jdbc);
|
||||
}
|
||||
```
|
||||
|
||||
Nothing creates the tables. The Javadoc points at
|
||||
`classpath:org/springframework/security/user-credentials-schema.sql`, and searching
|
||||
`spring-security-webauthn-7.1.1.jar` for it finds nothing — the two DDL files stayed in
|
||||
`spring-security-web` when the classes moved out in 7.0 ([01](01-versions.md)). They are still
|
||||
on the classpath transitively, so this works:
|
||||
|
||||
```yaml
|
||||
spring:
|
||||
sql:
|
||||
init:
|
||||
mode: always
|
||||
schema-locations:
|
||||
- classpath:org/springframework/security/user-entities-schema.sql
|
||||
- classpath:org/springframework/security/user-credentials-schema.sql
|
||||
```
|
||||
|
||||
There is a Postgres variant, `user-credentials-schema-postgres.sql`, which differs only in
|
||||
`bytea` versus `blob`. There is no Postgres variant of the user entities schema, because it
|
||||
does not need one.
|
||||
|
||||
Run it with `./scripts/run.sh jdbc`; the ceremony transcript is
|
||||
[`docs/output/pk-jdbc.txt`](../output/pk-jdbc.txt) and is identical to the in-memory one, which
|
||||
is the point.
|
||||
|
||||
## What a credential record holds
|
||||
|
||||
```
|
||||
credential_id the raw id, base64url
|
||||
user_entity_user_id the random user handle, not the username
|
||||
public_key the COSE key, as CBOR bytes
|
||||
signature_count updated on every assertion - see chapter 10
|
||||
uv_initialized whether UV was set at registration
|
||||
backup_eligible / backup_state whether this is a syncable passkey, and whether it is synced
|
||||
authenticator_transports internal, hybrid, usb, nfc, ble
|
||||
attestation_object the full attestation object from registration
|
||||
attestation_client_data_json the client data from registration
|
||||
created / last_used timestamps
|
||||
label whatever the user typed
|
||||
```
|
||||
|
||||
`attestation_object` is nullable in the DDL and mandatory in practice.
|
||||
`Webauthn4JRelyingPartyOperations.authenticate` re-parses it on every login to recover the COSE
|
||||
key:
|
||||
|
||||
```java
|
||||
Bytes attestationObject = credentialRecord.getAttestationObject();
|
||||
Assert.notNull(attestationObject, "attestationObject cannot be null");
|
||||
AttestationObject wa4jAttestationObject = cborConverter.readValue(attestationObject.getBytes(), ...);
|
||||
```
|
||||
|
||||
So a custom `UserCredentialRepository` that stores the public key but drops the attestation
|
||||
object — a reasonable-looking optimisation, since the public key is right there in its
|
||||
own column — produces credentials that register fine and can never log in. The symptom is
|
||||
a bare 401. That re-parse is also the mechanism behind
|
||||
[chapter 10](10-signature-counter.md).
|
||||
|
||||
## Things to decide before you ship
|
||||
|
||||
- **Deleting a credential.** `DELETE /webauthn/register/{id}` exists and is guarded by `CredentialRecordOwnerAuthorizationManager`, so users cannot delete each other's passkeys. Make sure your UI exposes it, and make sure a user cannot delete their last one.
|
||||
- **Labels.** The label is user-supplied and displayed back; treat it as untrusted, and cap its length. The column is `varchar(1000)`.
|
||||
- **Session storage.** The challenge lives in the `HttpSession` between the options call and the ceremony call. Behind a load balancer without sticky sessions, registration fails intermittently and nothing else does. Spring Session with Redis has its own open issue — [spring-security#16328](https://github.com/spring-projects/spring-security/issues/16328).
|
||||
- **Backups.** In a passwordless application the credential table *is* the account. Losing it is not a data-loss incident, it is a lockout incident for every user at once.
|
||||
|
||||
[← 08 — The one-time-token fallback](08-one-time-token-fallback.md) · [index](README.md) · next: [10 — The signature counter](10-signature-counter.md)
|
||||
138
docs/passkeys/10-signature-counter.md
Normal file
138
docs/passkeys/10-signature-counter.md
Normal file
@@ -0,0 +1,138 @@
|
||||
[← 09 — Persistence](09-persistence.md) · [index](README.md) · next: [11 — Should you build this](11-should-you.md)
|
||||
|
||||
# The signature counter is stored on every login and compared against on none
|
||||
|
||||
WebAuthn's signature counter exists for one purpose: detecting a cloned authenticator. A
|
||||
hardware key increments a monotonic counter each time it signs, so if the relying party ever
|
||||
sees a counter it has already seen, two copies of the private key are in circulation.
|
||||
|
||||
Spring Security persists that counter faithfully. It never reads it back for verification.
|
||||
|
||||
## Watching it happen
|
||||
|
||||
[`scripts/counter.sh`](../../passkeys/scripts/counter.sh) registers one credential, then
|
||||
authenticates three times with counters 1, 2 and 3, checking what the relying party stored
|
||||
after each. Then it presents a counter of 1 again. From
|
||||
[`docs/output/pk-counter.txt`](../output/pk-counter.txt):
|
||||
|
||||
```
|
||||
--- assertion 1, authenticator signCount = 1
|
||||
$ POST /login/webauthn
|
||||
HTTP 200
|
||||
{"authenticated":true,"redirectUrl":"/"}
|
||||
stored signatureCount now: 1
|
||||
|
||||
--- assertion 2, authenticator signCount = 2
|
||||
stored signatureCount now: 2
|
||||
|
||||
--- assertion 3, authenticator signCount = 3
|
||||
stored signatureCount now: 3
|
||||
|
||||
=== Replaying a stale counter. A cloned key would look exactly like this ===
|
||||
--- assertion 4, authenticator signCount = 1 (lower than the stored 3)
|
||||
$ POST /login/webauthn
|
||||
HTTP 200
|
||||
{"authenticated":true,"redirectUrl":"/"}
|
||||
|
||||
replayed a counter of 1 after the relying party had stored 3: HTTP 200
|
||||
```
|
||||
|
||||
The stored value walks 1, 2, 3 exactly as you would hope. Then a counter of 1 is accepted.
|
||||
|
||||
## Why
|
||||
|
||||
WebAuthn4J *does* implement the check. `AuthenticationDataVerifier` follows the specification
|
||||
line by line:
|
||||
|
||||
```java
|
||||
long presentedSignCount = authenticatorData.getSignCount();
|
||||
long storedSignCount = authenticator.getCounter();
|
||||
if (presentedSignCount > 0 || storedSignCount > 0) {
|
||||
if (presentedSignCount > storedSignCount) { ... }
|
||||
else { maliciousCounterValueHandler.maliciousCounterValueDetected(authenticationObject); }
|
||||
}
|
||||
```
|
||||
|
||||
and the default handler throws `MaliciousCounterValueException` with the message
|
||||
“Malicious counter value is detected. Cloned authenticators exist in parallel.”
|
||||
|
||||
The gap is in what `authenticator.getCounter()` returns. Spring builds the WebAuthn4J
|
||||
credential record like this:
|
||||
|
||||
```java
|
||||
AttestationObject wa4jAttestationObject = cborConverter.readValue(attestationObject.getBytes(), AttestationObject.class);
|
||||
com.webauthn4j.credential.CredentialRecord wa4jCredentialRecord =
|
||||
new CredentialRecordImpl(wa4jAttestationObject, null, null, transports);
|
||||
```
|
||||
|
||||
— `Webauthn4JRelyingPartyOperations.authenticate`, Spring Security 7.1.1
|
||||
|
||||
and that constructor derives the counter from the attestation object:
|
||||
|
||||
```java
|
||||
public CoreCredentialRecordImpl(@NotNull AttestationObject attestationObject) {
|
||||
super(attestationObject.getAuthenticatorData().getAttestedCredentialData(),
|
||||
attestationObject.getAttestationStatement(),
|
||||
attestationObject.getAuthenticatorData().getSignCount(), // <-- the counter
|
||||
attestationObject.getAuthenticatorData().getExtensions());
|
||||
```
|
||||
|
||||
— `CoreCredentialRecordImpl`, WebAuthn4J 0.31.9
|
||||
|
||||
The attestation object is the one captured at **registration**. Its counter is frozen at
|
||||
whatever the authenticator reported then — almost always 0. So the comparison is always
|
||||
“is the presented counter greater than 0?”, the persisted `signature_count` column
|
||||
is never consulted, and the only assertion that would ever trip the check is one presenting a
|
||||
counter of exactly 0 against a registration counter of 0. Which is precisely what every
|
||||
synced passkey does, on every login, forever — and those are not rejected either, because
|
||||
of the `presentedSignCount > 0 || storedSignCount > 0` guard.
|
||||
|
||||
The contract test
|
||||
[`signatureCounterIsStoredButNotVerified`](../../passkeys/src/test/java/com/ankurm/passkeys/PasskeyContractTests.java)
|
||||
pins this at the operations level, with no HTTP involved, and will start failing the day it
|
||||
changes.
|
||||
|
||||
## How much does this matter?
|
||||
|
||||
Less than the paragraphs above suggest, and it is worth being precise about why.
|
||||
|
||||
**The specification treats a mismatch as a signal, not a verdict.** WebAuthn Level 3 does make
|
||||
the comparison a step in the assertion verification procedure. What it does *not* do is
|
||||
prescribe the response: a counter that fails to increase is “a signal, but not proof,
|
||||
that the authenticator may be cloned”, since it might equally be a malfunctioning
|
||||
authenticator or assertions processed out of order, and relying parties are told to
|
||||
“evaluate their own operational characteristics and incorporate this information into
|
||||
their risk scoring”. Spring Security sits at the permissive end of that range rather
|
||||
than outside it.
|
||||
|
||||
**Modern passkeys have no counter.** A credential synced through iCloud Keychain, Google
|
||||
Password Manager or 1Password exists on several devices by design; a monotonic counter is
|
||||
meaningless across them, so those authenticators report 0 permanently. For a consumer
|
||||
application where most credentials are syncable, counter checking would detect nothing and
|
||||
would risk false positives.
|
||||
|
||||
**It is still worth knowing.** If you are enrolling FIDO2 hardware keys in an enterprise
|
||||
setting, the counter is real, it increments, and the check is a genuine clone detector —
|
||||
and you do not have it. The `signature_count` column filling up correctly makes it look like
|
||||
you do, which is the actual hazard here: not a hole, but a control you might believe you have.
|
||||
|
||||
## If you need it
|
||||
|
||||
Nothing in the API blocks you. Wrap the operations bean:
|
||||
|
||||
```java
|
||||
@Bean
|
||||
WebAuthnRelyingPartyOperations relyingPartyOperations(UserCredentialRepository credentials, ...) {
|
||||
Webauthn4JRelyingPartyOperations delegate = new Webauthn4JRelyingPartyOperations(...);
|
||||
return new CounterCheckingRelyingPartyOperations(delegate, credentials);
|
||||
}
|
||||
```
|
||||
|
||||
where the wrapper reads `credentials.findByCredentialId(id).getSignatureCount()` before
|
||||
delegating, remembers it, and after `authenticate` returns compares it against the value the
|
||||
delegate has just written — rejecting the login if it did not increase and either value
|
||||
was non-zero. That reproduces the specification's rule using the column Spring already
|
||||
maintains. Alert rather than block if syncable passkeys are in the mix; a permanent 0 must not
|
||||
lock anybody out.
|
||||
|
||||
[← 09 — Persistence](09-persistence.md) · [index](README.md) · next: [11 — Should you build this](11-should-you.md)
|
||||
49
docs/passkeys/11-should-you.md
Normal file
49
docs/passkeys/11-should-you.md
Normal file
@@ -0,0 +1,49 @@
|
||||
[← 10 — The signature counter](10-signature-counter.md) · [index](README.md)
|
||||
|
||||
# Should you build this
|
||||
|
||||
## Yes, if
|
||||
|
||||
- **You have consumer users and a password reset problem.** Passkeys remove credential stuffing and phishing in one move, and both are probably in your incident history.
|
||||
- **You already federate.** If most users arrive through Google or Apple, they already have a passkey-capable account recovery story, and you are adding a first-party option rather than inventing one.
|
||||
- **You can afford a second credential.** Passkeys are excellent when a user has two. They are a support burden when a user has one and drops their phone in a canal.
|
||||
- **Your users are on current devices.** Platform authenticator support is effectively universal on hardware from the last few years, and effectively absent below it.
|
||||
|
||||
## No, or not yet, if
|
||||
|
||||
- **Your recovery path is an email magic link and nothing else.** Then your security level is your users' email accounts, and you have added complexity without adding strength. Fix recovery first; the passkeys will still be there.
|
||||
- **You cannot run HTTPS everywhere, including in development.** `localhost` is the only exception browsers make. A staging environment on plain HTTP simply cannot run the ceremony.
|
||||
- **You are behind a load balancer without sticky sessions and cannot add shared session storage.** The challenge lives in the `HttpSession`. Registration will fail intermittently and the logs will not obviously say why.
|
||||
- **You need attestation.** Verifying which model of authenticator a user holds is a real project on top of what Spring Security gives you ([05](05-defaults.md)), and requires a trust-anchor source such as the FIDO Metadata Service.
|
||||
- **You are counting on clone detection.** You do not have it ([10](10-signature-counter.md)).
|
||||
|
||||
## What it actually costs
|
||||
|
||||
The Spring Security part is genuinely small: one dependency, one DSL block, one
|
||||
`UserDetailsService`. Working passkey login in an afternoon is a fair estimate, and the
|
||||
built-in registration and login pages mean you can demonstrate it before writing any
|
||||
JavaScript.
|
||||
|
||||
The rest is not small:
|
||||
|
||||
| work | why it is not optional |
|
||||
|---|---|
|
||||
| a delivery channel for one-time tokens | the bootstrap problem ([06](06-the-bootstrap-problem.md)) |
|
||||
| a credential management UI | list, label, delete, and refuse to delete the last one |
|
||||
| recovery policy, rate limits, notifications | this is where your real security level is set |
|
||||
| a real front end | the built-in pages are a reference, not a product |
|
||||
| persistence and backups | the credential table is the account ([09](09-persistence.md)) |
|
||||
| a fallback for unsupported clients | which means keeping passwords or federation alive during the migration |
|
||||
|
||||
The honest framing is that Spring Security 7.1 has removed the protocol from your list of
|
||||
problems, and left you with all the product ones. That is a good trade — it is just not
|
||||
the same as being finished.
|
||||
|
||||
## Where to go next
|
||||
|
||||
- [`docs/01`–`18`](../) — JWT authentication and OAuth2 resource servers, the other two projects in this repository
|
||||
- [`docs/authorization-server/`](../authorization-server/README.md) — running your own OAuth2 / OIDC provider, which is the other way to solve the bootstrap problem
|
||||
- [WebAuthn Level 3](https://www.w3.org/TR/webauthn-3/) — the specification, and readable
|
||||
- [passkeys.dev](https://passkeys.dev) — device and browser support, and the UX conventions users now expect
|
||||
|
||||
[← 10 — The signature counter](10-signature-counter.md) · [index](README.md)
|
||||
70
docs/passkeys/README.md
Normal file
70
docs/passkeys/README.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Passkeys and WebAuthn with Spring Security 7.1
|
||||
|
||||
Companion documentation for
|
||||
[Passkeys and WebAuthn with Spring Security 7](https://ankurm.com/passkeys-webauthn-spring-security-7/)
|
||||
on ankurm.com, and for the code in [`passkeys/`](../../passkeys).
|
||||
|
||||
The other three projects in this repository move bearer tokens around. This one gets rid of
|
||||
the password — and then spends most of its length on the parts that are not the
|
||||
ceremony, because the ceremony is the easy half.
|
||||
|
||||
Everything here was run. There is no browser and no hardware key anywhere in this module: a
|
||||
[software authenticator](04-virtual-authenticator.md) produces genuine CBOR attestation objects
|
||||
and genuine ES256 assertion signatures, and Spring Security verifies them without noticing.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| JDK | Temurin **25.0.4.1+1** (current LTS) |
|
||||
| Spring Boot | **4.1.1** |
|
||||
| Spring Framework | **7.0.9** |
|
||||
| Spring Security | **7.1.1** (GA 20 August 2026) |
|
||||
| `spring-security-webauthn` | **7.1.1** — a separate artifact since 7.0 |
|
||||
| WebAuthn4J | **0.31.9.RELEASE** |
|
||||
| Jackson | **3.1.5** (`tools.jackson`) |
|
||||
| Tomcat | **11.0.24** |
|
||||
| Maven | 3.9.11 |
|
||||
|
||||
Every file in [`docs/output/pk-*.txt`](../output) is real program output, regenerated by
|
||||
[`passkeys/scripts/run-all.sh`](../../passkeys/scripts/run-all.sh).
|
||||
|
||||
## Chapters
|
||||
|
||||
| # | chapter | what it settles |
|
||||
|---|---|---|
|
||||
| 01 | [Versions, artifacts and the 7.0 split](01-versions.md) | the dependency `spring-boot-starter-security` does not give you |
|
||||
| 02 | [The minimum configuration](02-minimum-configuration.md) | six endpoints from one DSL block, and the bean that silently disables it |
|
||||
| 03 | [The two ceremonies](03-the-two-ceremonies.md) | what is on the wire, and what every default in the options object means |
|
||||
| 04 | [A software authenticator](04-virtual-authenticator.md) | how to execute a passkey ceremony in CI, with no browser |
|
||||
| 05 | [The defaults](05-defaults.md) | user verification is optional, and asking for attestation is not checking it |
|
||||
| 06 | [The bootstrap problem](06-the-bootstrap-problem.md) | a passkey cannot be a user's first credential |
|
||||
| 07 | [Failure modes](07-failure-modes.md) | why every registration failure is a 500 and every login failure is a bare 401 |
|
||||
| 08 | [The one-time-token fallback](08-one-time-token-fallback.md) | the way in, the way back, and the rate limit that does not exist |
|
||||
| 09 | [Persistence](09-persistence.md) | the in-memory default, the missing DDL, and the column you must not drop |
|
||||
| 10 | [The signature counter](10-signature-counter.md) | stored on every login, compared against on none |
|
||||
| 11 | [Should you build this](11-should-you.md) | the honest answer, and what the afternoon actually costs |
|
||||
|
||||
## Captured output
|
||||
|
||||
| file | produced by | shows |
|
||||
|---|---|---|
|
||||
| [`pk-ceremony.txt`](../output/pk-ceremony.txt) | `scripts/ceremony.sh` | registration and authentication, end to end |
|
||||
| [`pk-counter.txt`](../output/pk-counter.txt) | `scripts/counter.sh` | a stale signature counter being accepted |
|
||||
| [`pk-user-verification.txt`](../output/pk-user-verification.txt) | `scripts/user-verification.sh` | `preferred` versus `required`, same authenticator |
|
||||
| [`pk-origin.txt`](../output/pk-origin.txt) | `scripts/origin.sh` | the phishing defence, in both ceremonies |
|
||||
| [`pk-attestation.txt`](../output/pk-attestation.txt) | `scripts/attestation.sh` | `direct` requested, `none` accepted |
|
||||
| [`pk-duplicate.txt`](../output/pk-duplicate.txt) | `scripts/duplicate.sh` | `excludeCredentials`, and a client that ignores it |
|
||||
| [`pk-bootstrap.txt`](../output/pk-bootstrap.txt) | `scripts/bootstrap.sh` | registration options with nobody logged in |
|
||||
| [`pk-step-up.txt`](../output/pk-step-up.txt) | `scripts/step-up.sh` | `FACTOR_WEBAUTHN` versus `FACTOR_OTT` on one endpoint |
|
||||
| [`pk-ott.txt`](../output/pk-ott.txt) | `scripts/ott-fallback.sh` | generate, redeem, and redeem again |
|
||||
| [`pk-jdbc.txt`](../output/pk-jdbc.txt) | `scripts/jdbc.sh` | the same ceremony against H2 |
|
||||
| [`pk-filters.txt`](../output/pk-filters.txt) | `scripts/filters.sh` | the live filter chain, all 25 of it |
|
||||
| [`pk-test-run.txt`](../output/pk-test-run.txt) | `scripts/test-run.sh` | seven contract tests |
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
cd spring-auth-demo/passkeys
|
||||
./scripts/run.sh # http://localhost:8080/login, user/password
|
||||
./scripts/ceremony.sh # both ceremonies, no browser
|
||||
./scripts/run-all.sh # regenerate every docs/output/pk-*.txt
|
||||
```
|
||||
Reference in New Issue
Block a user