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

109
README.md
View File

@@ -1,18 +1,20 @@
# spring-auth-demo
Runnable companion code for three articles on [ankurm.com](https://ankurm.com):
Runnable companion code for four articles on [ankurm.com](https://ankurm.com):
| | article | code |
|---|---|---|
| 1 | [Spring Security 7.1 JWT Authentication: The Complete Guide (Spring Boot 4.1)](https://ankurm.com/spring-security-7-1-jwt-authentication-guide/) | [`jwt-authentication/`](jwt-authentication) |
| 2 | [Spring Security OAuth2 Resource Server: JWT Validation, JWKS and Key Rotation](https://ankurm.com/spring-security-oauth2-resource-server-jwks-key-rotation/) | [`oauth2-resource-server/`](oauth2-resource-server) |
| 3 | [Spring Authorization Server: Running Your Own OAuth2 / OIDC Provider](https://ankurm.com/spring-authorization-server-oauth2-oidc-provider/) | [`authorization-server/`](authorization-server) |
| 4 | [Passkeys and WebAuthn with Spring Security 7](https://ankurm.com/passkeys-webauthn-spring-security-7/) | [`passkeys/`](passkeys) |
Three Maven projects, one shared [`docs/`](docs) tree. The first mints and verifies its own
Four Maven projects, one shared [`docs/`](docs) tree. The first mints and verifies its own
tokens with a hand-written filter. The second verifies tokens minted by somebody else —
a real Keycloak, and a stub issuer whose signing keys can be rotated on command. The third
*is* the somebody else: a real OAuth2 / OIDC provider, with a client and a resource server
in front of it.
in front of it. The fourth gets rid of the password entirely, and runs both WebAuthn
ceremonies with no browser and no hardware key.
> This repository was called `jwt-auth-demo` until the third project landed. Gitea keeps the
> old URL redirecting, but please update any bookmarks to `spring-auth-demo`.
@@ -32,6 +34,8 @@ real program output, regenerated by a script — not transcribed by hand.
| Spring Authorization Server | **7.1.1** — the same artifact, now versioned with Spring Security |
| Keycloak | **26.7.2** (resource server project only) |
| Caffeine | **3.2.4** (resource server project only) |
| WebAuthn4J | **0.31.9.RELEASE** (passkeys project only) |
| H2 | **2.4.240** (passkeys project only) |
---
@@ -78,6 +82,16 @@ cd spring-auth-demo/authorization-server
./scripts/authcode-pkce.sh
```
### Project 4 — passkeys, with no browser and no hardware key
```bash
cd spring-auth-demo/passkeys
./scripts/run.sh # http://localhost:8080/login, user/password
./scripts/ceremony.sh # registration and authentication, end to end
./scripts/counter.sh # a replayed signature counter, accepted
```
---
## Project 1 — `jwt-authentication/`
@@ -249,9 +263,55 @@ Regenerate its captured output with `./authorization-server/scripts/run-all.sh`
---
## Project 4 — `passkeys/`
One application on **:8080**, with `rpId` `localhost` — the only host browsers treat as a
secure context without TLS.
Two users: `user` / `password` and `admin` / `password`. Both start with a password, because
[a passkey cannot be a user's first credential](docs/passkeys/06-the-bootstrap-problem.md).
The interesting part is that this module needs no browser.
[`VirtualAuthenticator`](passkeys/src/main/java/com/ankurm/passkeys/virtual/VirtualAuthenticator.java)
is a software authenticator that emits genuine CBOR attestation objects and genuine ES256
assertion signatures; [`tools/PasskeyCeremony.java`](passkeys/tools/PasskeyCeremony.java)
drives the real HTTP endpoints with it, CSRF tokens and cookie jar included.
### Profiles
| profile | what it changes |
|---|---|
| *(none)* | `rpId` `localhost`, user verification `preferred`, credentials in memory |
| `uvrequired` | `UserVerificationRequirement.REQUIRED` on **both** ceremonies |
| `attestationdirect` | asks for `DIRECT` attestation, and accepts `none` anyway |
| `badorigin` | the relying party expects an origin the client will not send |
| `jdbc` | H2, using the DDL that ships inside `spring-security-web` |
| `trace` | `DEBUG` for Spring Security and WebAuthn4J — the only place failures are visible |
### Endpoints
| method | path | rule | why it exists |
|---|---|---|---|
| `POST` | `/webauthn/register/options` | authenticated | issues the creation options; **400** if nobody is logged in |
| `POST` | `/webauthn/register` | authenticated | verifies the attestation; **500** on any failure |
| `DELETE` | `/webauthn/register/{id}` | owner only | guarded by `CredentialRecordOwnerAuthorizationManager` |
| `POST` | `/webauthn/authenticate/options` | `permitAll()` | issues the request options |
| `POST` | `/login/webauthn` | `permitAll()` | verifies the assertion; a bare **401** on any failure |
| `POST` | `/ott/generate` | `permitAll()` | the magic-link fallback. No rate limit ships with it |
| `POST` | `/login/ott` | `permitAll()` | redeems a one-time token, once |
| `GET` | `/me` | authenticated | prints which factor the session actually carries |
| `GET` | `/passkey-only` | `hasAuthority('FACTOR_WEBAUTHN')` | step-up: a magic-link session gets redirected, not admitted |
| `GET` | `/diag/credentials` | authenticated | **the stored `CredentialRecord`. Delete before shipping** |
| `GET` | `/diag/filters` | authenticated | the live filter chain |
Regenerate its captured output with `./passkeys/scripts/run-all.sh` (no Docker; a few minutes).
---
## Documentation
One numbered trail across the first two projects, plus a separate set for the third. Start at
One numbered trail across the first two projects, plus a separate set for the third and the
fourth. Start at
[`docs/01-architecture.md`](docs/01-architecture.md).
| doc | covers |
@@ -293,6 +353,25 @@ A separate chapter set, indexed at
| [09 — Entry point and the Accept header](docs/authorization-server/09-entry-point.md) | why the token endpoint 302s to a login page |
| [10 — Should you run one at all](docs/authorization-server/10-should-you.md) | the honest answer |
### Project 4 — passkeys and WebAuthn
A separate chapter set, indexed at
[`docs/passkeys/`](docs/passkeys/README.md).
| doc | covers |
|---|---|
| [01 — Versions, artifacts and the 7.0 split](docs/passkeys/01-versions.md) | the dependency `spring-boot-starter-security` does not give you |
| [02 — The minimum configuration](docs/passkeys/02-minimum-configuration.md) | six endpoints from one DSL block, and the bean that silently disables it |
| [03 — The two ceremonies](docs/passkeys/03-the-two-ceremonies.md) | what is on the wire, and what every default in the options object means |
| [04 — A software authenticator](docs/passkeys/04-virtual-authenticator.md) | how to execute a passkey ceremony in CI, with no browser |
| [05 — The defaults](docs/passkeys/05-defaults.md) | user verification is optional, and asking for attestation is not checking it |
| [06 — The bootstrap problem](docs/passkeys/06-the-bootstrap-problem.md) | a passkey cannot be a user's first credential |
| [07 — Failure modes](docs/passkeys/07-failure-modes.md) | why registration failures are 500s and login failures are bare 401s |
| [08 — The one-time-token fallback](docs/passkeys/08-one-time-token-fallback.md) | the way in, the way back, and the rate limit that does not exist |
| [09 — Persistence](docs/passkeys/09-persistence.md) | the in-memory default, the missing DDL, and the column you must not drop |
| [10 — The signature counter](docs/passkeys/10-signature-counter.md) | stored on every login, compared against on none |
| [11 — Should you build this](docs/passkeys/11-should-you.md) | the honest answer, and what the afternoon actually costs |
---
## Captured output
@@ -345,6 +424,23 @@ opening first:
| [`as-client-credentials-opaque.txt`](docs/output/as-client-credentials-opaque.txt) | a reference token, and what introspection returns for it |
| [`as-test-run.txt`](docs/output/as-test-run.txt) | 7 contract tests |
### Project 4
Indexed in full at [`docs/passkeys/README.md`](docs/passkeys/README.md). The ones worth
opening first:
| file | what it shows |
|---|---|
| [`pk-ceremony.txt`](docs/output/pk-ceremony.txt) | both WebAuthn ceremonies, end to end, no browser |
| [`pk-counter.txt`](docs/output/pk-counter.txt) | a signature counter of 1 accepted after the server stored 3 |
| [`pk-user-verification.txt`](docs/output/pk-user-verification.txt) | `uvInitialized: false`, and a successful login anyway |
| [`pk-attestation.txt`](docs/output/pk-attestation.txt) | `"attestation":"direct"` requested, `fmt: "none"` accepted |
| [`pk-origin.txt`](docs/output/pk-origin.txt) | `BadOriginException`, and the 500 and 401 it produces |
| [`pk-step-up.txt`](docs/output/pk-step-up.txt) | `?factor.type=webauthn&factor.reason=missing` |
| [`pk-bootstrap.txt`](docs/output/pk-bootstrap.txt) | a 400 from `/webauthn/register/options` with no session |
| [`pk-filters.txt`](docs/output/pk-filters.txt) | all 25 filters, and where the WebAuthn four land |
| [`pk-test-run.txt`](docs/output/pk-test-run.txt) | 7 contract tests |
---
## Security note
@@ -366,6 +462,11 @@ signing key is generated fresh on every boot, and its users are hard-coded. Read
[docs/authorization-server/10-should-you.md](docs/authorization-server/10-should-you.md)
before taking any of it near production.
The passkeys project's `/diag/credentials` prints credential ids and user handles to any
authenticated caller, and `ConsoleOneTimeTokenHandler` writes live one-time tokens to a file
in `/tmp` so the scripts can read them. Both are demo affordances. Delete them, and read
[docs/passkeys/11-should-you.md](docs/passkeys/11-should-you.md) first.
## License
MIT.

View File

@@ -0,0 +1,34 @@
==============================================================================
attestation: DIRECT requested, attestation: none accepted
==============================================================================
=== Registration ceremony, then authentication ceremony, no browser involved ===
POST /login (password) -> HTTP 302, Location: http://localhost:8080/
$ POST /webauthn/register/options
HTTP 200
{"attestation":"direct","authenticatorSelection":{"residentKey":"required","userVerification":"preferred"},"challenge":"EDPeleI6Jl4Bjc6-cOqnnZqNHCt0P921DI6jsCNvCBQ","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":"OahmMEZ7JKUXyrKH9QxxzqtW7tnuMfUhmnHnmpg80Bc","displayName":"user"}}
authenticator produced credentialId m9GhJSmVMlSefrRpC7EitQ and a 178-byte CBOR attestation object
$ POST /webauthn/register
HTTP 200
{"success":true}
$ GET /diag/credentials
HTTP 200
{"principal":"user","principalType":"UsernamePasswordAuthenticationToken","authorities":["FactorGrantedAuthority [authority=FACTOR_PASSWORD, issuedAt=2026-08-25T17:20:10.080515066Z]","ROLE_USER"],"userEntity":{"name":"user","displayName":"user","id":"OahmMEZ7JKUXyrKH9QxxzqtW7tnuMfUhmnHnmpg80Bc"},"credentials":[{"label":"yubikey-on-my-desk","credentialId":"m9GhJSmVMlSefrRpC7EitQ","signatureCount":0,"uvInitialized":true,"backupEligible":true,"backupState":true,"transports":["hybrid","internal"],"attestationObjectBytes":178,"created":"2026-08-25T17:20:10.707040039Z","lastUsed":"2026-08-25T17:20:10.707040039Z"}]}
logged out, cookie jar emptied
=== Session dropped. Authenticating with the passkey alone ===
$ POST /webauthn/authenticate/options
HTTP 200
{"allowCredentials":[],"challenge":"01OJTPvWOBAxxyk3vTq1FFSaVBEqw-_tMMj2FDlFDq8","extensions":{},"rpId":"localhost","timeout":300000,"userVerification":"preferred"}
$ POST /login/webauthn
HTTP 200
{"authenticated":true,"redirectUrl":"/"}
$ GET /me
HTTP 200
{"name":"user","authenticationType":"WebAuthnAuthentication","authorities":["FactorGrantedAuthority [authority=FACTOR_WEBAUTHN, issuedAt=2026-08-25T17:20:11.002451577Z]","ROLE_USER"]}

View File

@@ -0,0 +1,15 @@
==============================================================================
Bootstrapping - registering a passkey requires an existing authenticated session
==============================================================================
=== Asking for registration options with nobody logged in ===
$ POST /webauthn/register/options (anonymous)
HTTP 400
(empty body)
=== A one-time token for a username that does not exist ===
POST /ott/generate -> HTTP 302, Location: http://localhost:8080/login/ott
a token was still generated and delivered: d75e51fe-cbde-4add-8d70-231fc6ca490f
the response is byte-for-byte what a real username produces - no enumeration oracle
POST /login/ott -> HTTP 302, Location: http://localhost:8080/login?error (the failure lands here instead)

View File

@@ -0,0 +1,36 @@
==============================================================================
Registration and authentication ceremonies, driven without a browser
==============================================================================
Spring Security 7.1.1, Spring Boot 4.1.1, rpId localhost, default settings.
The authenticator is src/main/java/com/ankurm/passkeys/virtual/VirtualAuthenticator.java.
=== Registration ceremony, then authentication ceremony, no browser involved ===
POST /login (password) -> HTTP 302, Location: http://localhost:8080/
$ POST /webauthn/register/options
HTTP 200
{"attestation":"none","authenticatorSelection":{"residentKey":"required","userVerification":"preferred"},"challenge":"cfccFSLkLYqwn_qsRIDEpAa8fUnVtxkAB82NBijQyyA","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":"qJBkbDbHGSVdnYG0VqD0JAN-t7GAejJ3Lw3HDL9bKow","displayName":"user"}}
authenticator produced credentialId lAfNKiLC_virGa8Yrfr4gA and a 178-byte CBOR attestation object
$ POST /webauthn/register
HTTP 200
{"success":true}
$ GET /diag/credentials
HTTP 200
{"principal":"user","principalType":"UsernamePasswordAuthenticationToken","authorities":["FactorGrantedAuthority [authority=FACTOR_PASSWORD, issuedAt=2026-08-25T17:18:48.132461443Z]","ROLE_USER"],"userEntity":{"displayName":"user","name":"user","id":"qJBkbDbHGSVdnYG0VqD0JAN-t7GAejJ3Lw3HDL9bKow"},"credentials":[{"label":"yubikey-on-my-desk","credentialId":"lAfNKiLC_virGa8Yrfr4gA","signatureCount":0,"uvInitialized":true,"backupEligible":true,"backupState":true,"transports":["hybrid","internal"],"attestationObjectBytes":178,"created":"2026-08-25T17:18:48.764625858Z","lastUsed":"2026-08-25T17:18:48.764625858Z"}]}
logged out, cookie jar emptied
=== Session dropped. Authenticating with the passkey alone ===
$ POST /webauthn/authenticate/options
HTTP 200
{"allowCredentials":[],"challenge":"BUu8TaKJn6f43nZk4oL3GpD517F8cDol5XOlc7GULq8","extensions":{},"rpId":"localhost","timeout":300000,"userVerification":"preferred"}
$ POST /login/webauthn
HTTP 200
{"authenticated":true,"redirectUrl":"/"}
$ GET /me
HTTP 200
{"name":"user","authenticationType":"WebAuthnAuthentication","authorities":["FactorGrantedAuthority [authority=FACTOR_WEBAUTHN, issuedAt=2026-08-25T17:18:49.046877669Z]","ROLE_USER"]}

View File

@@ -0,0 +1,64 @@
==============================================================================
Signature counter: stored on every assertion, compared against on none
==============================================================================
=== Signature counter: does the relying party detect a cloned authenticator? ===
POST /login (password) -> HTTP 302, Location: http://localhost:8080/
$ POST /webauthn/register/options
HTTP 200
{"attestation":"none","authenticatorSelection":{"residentKey":"required","userVerification":"preferred"},"challenge":"U-Ag0aFP_XRcaPxY293PTL-bRtb263WeXHdkShFPY58","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":"YD7ijO_ydB9lpHfkEczKQ-RGqud-lI0XnrvNYxXOF0M","displayName":"user"}}
authenticator produced credentialId vALRaznkn_0DdHi9VgpA2A and a 178-byte CBOR attestation object
$ POST /webauthn/register
HTTP 200
{"success":true}
logged out, cookie jar emptied
--- assertion 1, authenticator signCount = 1
$ POST /webauthn/authenticate/options
HTTP 200
{"allowCredentials":[],"challenge":"cYWUR_Jdr9MZZ-mRwzk7lnDPd9zGjzbaoaJy_Hwybrk","extensions":{},"rpId":"localhost","timeout":300000,"userVerification":"preferred"}
$ POST /login/webauthn
HTTP 200
{"authenticated":true,"redirectUrl":"/"}
POST /login (password) -> HTTP 302, Location: http://localhost:8080/
stored signatureCount now: 1
logged out, cookie jar emptied
--- assertion 2, authenticator signCount = 2
$ POST /webauthn/authenticate/options
HTTP 200
{"allowCredentials":[],"challenge":"HWqOXFziV2H1LG98g2MGr5qvbbrBLgsPi1yOBTw73qg","extensions":{},"rpId":"localhost","timeout":300000,"userVerification":"preferred"}
$ POST /login/webauthn
HTTP 200
{"authenticated":true,"redirectUrl":"/"}
POST /login (password) -> HTTP 302, Location: http://localhost:8080/
stored signatureCount now: 2
logged out, cookie jar emptied
--- assertion 3, authenticator signCount = 3
$ POST /webauthn/authenticate/options
HTTP 200
{"allowCredentials":[],"challenge":"GqB6o-RP_G76sVZm6KVk1MHBGziU61UlVTd62lzerYo","extensions":{},"rpId":"localhost","timeout":300000,"userVerification":"preferred"}
$ POST /login/webauthn
HTTP 200
{"authenticated":true,"redirectUrl":"/"}
POST /login (password) -> HTTP 302, Location: http://localhost:8080/
stored signatureCount now: 3
logged out, cookie jar emptied
=== 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

View File

@@ -0,0 +1,22 @@
==============================================================================
Registering the same credential id twice
==============================================================================
=== Registering the same credential id twice ===
POST /login (password) -> HTTP 302, Location: http://localhost:8080/
$ POST /webauthn/register/options
HTTP 200
{"attestation":"none","authenticatorSelection":{"residentKey":"required","userVerification":"preferred"},"challenge":"ypikZgeGfU0eoZxEDstXG6v9tHy2iayNhk8nx9m6KUY","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":"AX63-GT5WAL-SV1hZ4Ok7H1NrOA5AjFkfu3SFW1XOz0","displayName":"user"}}
authenticator produced credentialId 7fFoG7Cjo2WI-baGw1ik7g and a 178-byte CBOR attestation object
$ POST /webauthn/register
HTTP 200
{"success":true}
same authenticator, same credential id, second registration:
excludeCredentials now: [{"id":"7fFoG7Cjo2WI-baGw1ik7g","transports":["hybrid","internal"]
$ POST /webauthn/register
HTTP 500
{"timestamp":"2026-08-25T17:20:25.951Z","status":500,"error":"Internal Server Error","path":"/webauthn/register"}

View File

@@ -0,0 +1,29 @@
==============================================================================
The security filter chain with webAuthn() and oneTimeTokenLogin() configured
==============================================================================
POST /login (password) -> HTTP 302, Location: http://localhost:8080/
1 DisableEncodeUrlFilter
2 WebAsyncManagerIntegrationFilter
3 SecurityContextHolderFilter
4 HeaderWriterFilter
5 CsrfFilter
6 LogoutFilter
7 GenerateOneTimeTokenFilter
8 UsernamePasswordAuthenticationFilter
9 OneTimeTokenAuthenticationFilter
10 DefaultResourcesFilter
11 DefaultResourcesFilter
12 DefaultResourcesFilter
13 DefaultLoginPageGeneratingFilter
14 DefaultLogoutPageGeneratingFilter
15 DefaultOneTimeTokenSubmitPageGeneratingFilter
16 WebAuthnAuthenticationFilter
17 RequestCacheAwareFilter
18 SecurityContextHolderAwareRequestFilter
19 AnonymousAuthenticationFilter
20 ExceptionTranslationFilter
21 PublicKeyCredentialCreationOptionsFilter
22 PublicKeyCredentialRequestOptionsFilter
23 AuthorizationFilter
24 WebAuthnRegistrationFilter
25 DefaultWebAuthnRegistrationPageGeneratingFilter

38
docs/output/pk-jdbc.txt Normal file
View File

@@ -0,0 +1,38 @@
==============================================================================
JDBC persistence - H2, with Spring Security's own schema
==============================================================================
schema-locations point at classpath:org/springframework/security/user-entities-schema.sql
and user-credentials-schema.sql, which live in spring-security-web, not in
spring-security-webauthn. Nothing creates these tables for you.
=== Registration ceremony, then authentication ceremony, no browser involved ===
POST /login (password) -> HTTP 302, Location: http://localhost:8080/
$ POST /webauthn/register/options
HTTP 200
{"attestation":"none","authenticatorSelection":{"residentKey":"required","userVerification":"preferred"},"challenge":"hahNpzhow1MxVRCiR8XSlgtl-C7u0a0jsv6J3REL-A8","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":"hfu2DnARNIH5VbqLz2F0gBR93S2Z9o2MmCakwWCgAgM","displayName":"user"}}
authenticator produced credentialId Pp2xpfO7m1rnhV0cqUDvvA and a 178-byte CBOR attestation object
$ POST /webauthn/register
HTTP 200
{"success":true}
$ GET /diag/credentials
HTTP 200
{"principal":"user","principalType":"UsernamePasswordAuthenticationToken","authorities":["FactorGrantedAuthority [authority=FACTOR_PASSWORD, issuedAt=2026-08-25T17:21:25.201127080Z]","ROLE_USER"],"userEntity":{"name":"user","displayName":"user","id":"hfu2DnARNIH5VbqLz2F0gBR93S2Z9o2MmCakwWCgAgM"},"credentials":[{"label":"yubikey-on-my-desk","credentialId":"Pp2xpfO7m1rnhV0cqUDvvA","signatureCount":0,"uvInitialized":true,"backupEligible":true,"backupState":true,"transports":["hybrid","internal"],"attestationObjectBytes":178,"created":"2026-08-25T17:21:25.819064Z","lastUsed":"2026-08-25T17:21:25.819064Z"}]}
logged out, cookie jar emptied
=== Session dropped. Authenticating with the passkey alone ===
$ POST /webauthn/authenticate/options
HTTP 200
{"allowCredentials":[],"challenge":"zS7lWt58FceogQ7sg7Uj6vfBOVgLafRlEeGpq5WZIjg","extensions":{},"rpId":"localhost","timeout":300000,"userVerification":"preferred"}
$ POST /login/webauthn
HTTP 200
{"authenticated":true,"redirectUrl":"/"}
$ GET /me
HTTP 200
{"name":"user","authenticationType":"WebAuthnAuthentication","authorities":["FactorGrantedAuthority [authority=FACTOR_WEBAUTHN, issuedAt=2026-08-25T17:21:26.157551281Z]","ROLE_USER"]}

39
docs/output/pk-origin.txt Normal file
View File

@@ -0,0 +1,39 @@
==============================================================================
Registration from a disallowed origin
==============================================================================
=== Client data from an origin the relying party did not allow ===
POST /login (password) -> HTTP 302, Location: http://localhost:8080/
origin sent by the client: http://evil.localhost:8080
origin allowed by the relying party: http://localhost:8080
$ POST /webauthn/register
HTTP 500
{"timestamp":"2026-08-25T17:19:51.884Z","status":500,"error":"Internal Server Error","path":"/webauthn/register"}
--- what the server logged ---
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) ~[webauthn4j-core-0.31.9.RELEASE.jar:na]
at com.webauthn4j.verifier.OriginVerifierImpl.verify(OriginVerifierImpl.java:48) ~[webauthn4j-core-0.31.9.RELEASE.jar:na]
at com.webauthn4j.verifier.RegistrationDataVerifier.verify(RegistrationDataVerifier.java:171) ~[webauthn4j-core-0.31.9.RELEASE.jar:na]
at com.webauthn4j.WebAuthnRegistrationManager.verify(WebAuthnRegistrationManager.java:337) ~[webauthn4j-core-0.31.9.RELEASE.jar:na]
==============================================================================
Assertion from a disallowed origin
==============================================================================
=== An assertion from a disallowed origin - the same mistake, one ceremony later ===
POST /login (password) -> HTTP 302, Location: http://localhost:8080/
$ POST /webauthn/register/options
HTTP 200
{"attestation":"none","authenticatorSelection":{"residentKey":"required","userVerification":"preferred"},"challenge":"IOcuyb_uTuoFxh1RYNr7H--vx1dVRNoM4CyXTdFK9cc","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":"aXe2d8pDxUar9cTAC93qmunRjRSnQolJcJaYQvFpoGk","displayName":"user"}}
authenticator produced credentialId IrIGbWFcDF7yRWZGkYJowQ and a 178-byte CBOR attestation object
$ POST /webauthn/register
HTTP 200
{"success":true}
logged out, cookie jar emptied
$ POST /login/webauthn (origin http://evil.localhost:8080)
HTTP 401
(empty body)

15
docs/output/pk-ott.txt Normal file
View File

@@ -0,0 +1,15 @@
==============================================================================
One-time token login - the way in, and the way back after a lost device
==============================================================================
=== One-time token: the way in when there is no passkey yet, and the way back ===
POST /ott/generate -> HTTP 302, Location: http://localhost:8080/login/ott
token delivered out of band (the handler wrote it to a file): c5a7e60d-acd0-48a7-bb90-c03195fd783b
POST /login/ott -> HTTP 302, Location: http://localhost:8080/
$ GET /me
HTTP 200
{"name":"user","authenticationType":"OneTimeTokenAuthentication","authorities":["FactorGrantedAuthority [authority=FACTOR_OTT, issuedAt=2026-08-25T17:21:08.899894517Z]","ROLE_USER"]}
=== The same token, a second time ===
POST /login/ott -> HTTP 302, Location: http://localhost:8080/login?error

View File

@@ -0,0 +1,41 @@
==============================================================================
FactorGrantedAuthority - password, magic link and passkey are not interchangeable
==============================================================================
=== An endpoint guarded by hasAuthority("FACTOR_WEBAUTHN") ===
POST /login (password) -> HTTP 302, Location: http://localhost:8080/
$ POST /webauthn/register/options
HTTP 200
{"attestation":"none","authenticatorSelection":{"residentKey":"required","userVerification":"preferred"},"challenge":"2LH6xjc9-LwKl-KldFIox5EkCGBVuDXps87yofG4piA","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":"p6EhVt4x7b3PsL3M94Vuczh2BQyWCSurlu1YBG6lAvs","displayName":"user"}}
authenticator produced credentialId AkXU5KRzDgeoY4RFXG0mqg and a 178-byte CBOR attestation object
$ POST /webauthn/register
HTTP 200
{"success":true}
password session -> GET /passkey-only: HTTP 302, Location: http://localhost:8080/login?factor.type=webauthn&factor.reason=missing
logged out, cookie jar emptied
=== One-time token: the way in when there is no passkey yet, and the way back ===
POST /ott/generate -> HTTP 302, Location: http://localhost:8080/login/ott
token delivered out of band (the handler wrote it to a file): 9ae7d90d-74fe-49ec-9502-fe857fe8972a
POST /login/ott -> HTTP 302, Location: http://localhost:8080/
$ GET /me
HTTP 200
{"name":"user","authenticationType":"OneTimeTokenAuthentication","authorities":["FactorGrantedAuthority [authority=FACTOR_OTT, issuedAt=2026-08-25T17:20:54.745020051Z]","ROLE_USER"]}
=== The same token, a second time ===
POST /login/ott -> HTTP 302, Location: http://localhost:8080/login?error
one-time-token session -> GET /passkey-only: HTTP 302, Location: http://localhost:8080/login?factor.type=webauthn&factor.reason=missing
logged out, cookie jar emptied
$ POST /login/webauthn
HTTP 200
{"authenticated":true,"redirectUrl":"/"}
$ passkey session -> GET /passkey-only
HTTP 200
{"ok":"this endpoint required FACTOR_WEBAUTHN"}

View File

@@ -0,0 +1,15 @@
[INFO] T E S T S
[INFO] -------------------------------------------------------
[INFO] Running com.ankurm.passkeys.PasskeyContractTests
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.942 s -- in com.ankurm.passkeys.PasskeyContractTests
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 6.331 s
[INFO] Finished at: 2026-08-25T22:52:02+05:30
[INFO] ------------------------------------------------------------------------

View File

@@ -0,0 +1,42 @@
==============================================================================
userVerification PREFERRED - the default
==============================================================================
=== A credential created and asserted with the UV flag clear ===
POST /login (password) -> HTTP 302, Location: http://localhost:8080/
$ POST /webauthn/register/options
HTTP 200
{"attestation":"none","authenticatorSelection":{"residentKey":"required","userVerification":"preferred"},"challenge":"dvOsS644o1RkfTwGf-OaGb5zAi2kv8J_CILmgnSyR_Q","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":"9kHjCZP7Duq18lf4acn_-dvW6csqKHiN0rOG6wQQysg","displayName":"user"}}
authenticator produced credentialId igJ93sbwKB1657SEcNaZiQ and a 178-byte CBOR attestation object
$ POST /webauthn/register
HTTP 200
{"success":true}
$ GET /diag/credentials (note uvInitialized)
HTTP 200
{"principal":"user","principalType":"UsernamePasswordAuthenticationToken","authorities":["FactorGrantedAuthority [authority=FACTOR_PASSWORD, issuedAt=2026-08-25T17:19:19.094840764Z]","ROLE_USER"],"userEntity":{"name":"user","id":"9kHjCZP7Duq18lf4acn_-dvW6csqKHiN0rOG6wQQysg","displayName":"user"},"credentials":[{"label":"no-uv","credentialId":"igJ93sbwKB1657SEcNaZiQ","signatureCount":0,"uvInitialized":false,"backupEligible":true,"backupState":true,"transports":["hybrid","internal"],"attestationObjectBytes":178,"created":"2026-08-25T17:19:19.696691237Z","lastUsed":"2026-08-25T17:19:19.696691237Z"}]}
logged out, cookie jar emptied
$ POST /login/webauthn
HTTP 200
{"authenticated":true,"redirectUrl":"/"}
authentication with UV clear: HTTP 200
==============================================================================
userVerification REQUIRED - the uvrequired profile
==============================================================================
=== A credential created and asserted with the UV flag clear ===
POST /login (password) -> HTTP 302, Location: http://localhost:8080/
$ POST /webauthn/register/options
HTTP 200
{"attestation":"none","authenticatorSelection":{"residentKey":"required","userVerification":"required"},"challenge":"HoePMBML6zmDi7dBhbG034OdothhFfJC0M4al6MU5F0","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":"5xLUhpfzTToevAbZn3eHAfBAhIwj1w8RmVhtqaZpBPE","displayName":"user"}}
authenticator produced credentialId BdpidHMhT0YRUI9YNpMhTQ and a 178-byte CBOR attestation object
$ POST /webauthn/register
HTTP 500
{"timestamp":"2026-08-25T17:19:36.938Z","status":500,"error":"Internal Server Error","path":"/webauthn/register"}
registration refused, which is what userVerification REQUIRED does

View 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
&mdash; 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 &mdash; 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 &mdash; 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.
[&larr; index](README.md) &middot; next: [02 &mdash; The minimum configuration](02-minimum-configuration.md)

View File

@@ -0,0 +1,100 @@
[&larr; 01 &mdash; Versions](01-versions.md) &middot; [index](README.md) &middot; next: [03 &mdash; 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 &mdash; 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)

View File

@@ -0,0 +1,97 @@
[&larr; 02 &mdash; The minimum configuration](02-minimum-configuration.md) &middot; [index](README.md) &middot; next: [04 &mdash; 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** &mdash; 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 &ldquo;passkey&rdquo;, 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** &mdash; 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 &mdash; 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 &mdash; 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 &mdash; The bootstrap problem](06-the-bootstrap-problem.md).
[&larr; 02 &mdash; The minimum configuration](02-minimum-configuration.md) &middot; [index](README.md) &middot; next: [04 &mdash; A software authenticator](04-virtual-authenticator.md)

View File

@@ -0,0 +1,96 @@
[&larr; 03 &mdash; The two ceremonies](03-the-two-ceremonies.md) &middot; [index](README.md) &middot; next: [05 &mdash; 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 &mdash; 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 &mdash; and by default Spring
Security does not. See [05 &mdash; 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 &mdash; not the operations bean directly &mdash; 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.
[&larr; 03 &mdash; The two ceremonies](03-the-two-ceremonies.md) &middot; [index](README.md) &middot; next: [05 &mdash; The defaults](05-defaults.md)

View File

@@ -0,0 +1,126 @@
[&larr; 04 &mdash; A software authenticator](04-virtual-authenticator.md) &middot; [index](README.md) &middot; next: [06 &mdash; 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 &mdash; 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 &mdash; 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 &mdash; which gets its own chapter,
[10 &mdash; The signature counter](10-signature-counter.md).
[&larr; 04 &mdash; A software authenticator](04-virtual-authenticator.md) &middot; [index](README.md) &middot; next: [06 &mdash; The bootstrap problem](06-the-bootstrap-problem.md)

View File

@@ -0,0 +1,106 @@
[&larr; 05 &mdash; The defaults](05-defaults.md) &middot; [index](README.md) &middot; next: [07 &mdash; 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 &ldquo;add passkeys&rdquo; 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");
}
```
&mdash; `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 &mdash; 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 &ldquo;log in again&rdquo;. 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 &mdash; 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 &ldquo;use your passkey for
this&rdquo; instead of &ldquo;access denied&rdquo;.
[&larr; 05 &mdash; The defaults](05-defaults.md) &middot; [index](README.md) &middot; next: [07 &mdash; Failure modes](07-failure-modes.md)

View File

@@ -0,0 +1,92 @@
[&larr; 06 &mdash; The bootstrap problem](06-the-bootstrap-problem.md) &middot; [index](README.md) &middot; next: [08 &mdash; 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 &mdash; 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 &mdash; plain HTTP on anything but `localhost` | browser console, not the server |
| credentials vanish on restart | `MapUserCredentialRepository`, the default | [09 &mdash; 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.
[&larr; 06 &mdash; The bootstrap problem](06-the-bootstrap-problem.md) &middot; [index](README.md) &middot; next: [08 &mdash; The one-time-token fallback](08-one-time-token-fallback.md)

View File

@@ -0,0 +1,99 @@
[&larr; 07 &mdash; Failure modes](07-failure-modes.md) &middot; [index](README.md) &middot; next: [09 &mdash; 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
&mdash; 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 &mdash; `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 &mdash; [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.
[&larr; 07 &mdash; Failure modes](07-failure-modes.md) &middot; [index](README.md) &middot; next: [09 &mdash; Persistence](09-persistence.md)

View File

@@ -0,0 +1,93 @@
[&larr; 08 &mdash; The one-time-token fallback](08-one-time-token-fallback.md) &middot; [index](README.md) &middot; next: [10 &mdash; 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 &mdash; 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 &mdash; a reasonable-looking optimisation, since the public key is right there in its
own column &mdash; 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 &mdash; [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.
[&larr; 08 &mdash; The one-time-token fallback](08-one-time-token-fallback.md) &middot; [index](README.md) &middot; next: [10 &mdash; The signature counter](10-signature-counter.md)

View File

@@ -0,0 +1,138 @@
[&larr; 09 &mdash; Persistence](09-persistence.md) &middot; [index](README.md) &middot; next: [11 &mdash; 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
&ldquo;Malicious counter value is detected. Cloned authenticators exist in parallel.&rdquo;
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);
```
&mdash; `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());
```
&mdash; `CoreCredentialRecordImpl`, WebAuthn4J 0.31.9
The attestation object is the one captured at **registration**. Its counter is frozen at
whatever the authenticator reported then &mdash; almost always 0. So the comparison is always
&ldquo;is the presented counter greater than 0?&rdquo;, 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 &mdash; 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 &ldquo;a signal, but not proof,
that the authenticator may be cloned&rdquo;, since it might equally be a malfunctioning
authenticator or assertions processed out of order, and relying parties are told to
&ldquo;evaluate their own operational characteristics and incorporate this information into
their risk scoring&rdquo;. 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 &mdash;
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 &mdash; 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.
[&larr; 09 &mdash; Persistence](09-persistence.md) &middot; [index](README.md) &middot; next: [11 &mdash; Should you build this](11-should-you.md)

View File

@@ -0,0 +1,49 @@
[&larr; 10 &mdash; The signature counter](10-signature-counter.md) &middot; [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 &mdash; it is just not
the same as being finished.
## Where to go next
- [`docs/01`&ndash;`18`](../) &mdash; JWT authentication and OAuth2 resource servers, the other two projects in this repository
- [`docs/authorization-server/`](../authorization-server/README.md) &mdash; 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/) &mdash; the specification, and readable
- [passkeys.dev](https://passkeys.dev) &mdash; device and browser support, and the UX conventions users now expect
[&larr; 10 &mdash; The signature counter](10-signature-counter.md) &middot; [index](README.md)

70
docs/passkeys/README.md Normal file
View 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 &mdash; 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** &mdash; 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
```

84
passkeys/pom.xml Normal file
View File

@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>passkeys-demo</artifactId>
<version>1.0.0</version>
<name>passkeys-demo</name>
<description>Passkeys and WebAuthn with Spring Security 7.1 on Spring Boot 4.1 - runnable companion for ankurm.com</description>
<!--
One application, port 8080, rpId "localhost".
The interesting part of this module is that it needs no browser. tools/PasskeyCeremony.java
drives the real HTTP endpoints (/webauthn/register/options, /webauthn/register,
/webauthn/authenticate/options, /login/webauthn) using a software authenticator that
produces genuine CBOR attestation objects and genuine ES256 assertion signatures -
see src/main/java/com/ankurm/passkeys/virtual/VirtualAuthenticator.java and
docs/passkeys/04-virtual-authenticator.md.
spring-security-webauthn is NOT part of spring-boot-starter-security. As of Spring
Security 7.0 the WebAuthn classes live in their own artifact; in 6.4 and 6.5 they were
inside spring-security-web. See docs/passkeys/01-versions.md.
-->
<properties>
<java.version>25</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- The one dependency the passkey docs are easy to miss. Version managed by Boot. -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-webauthn</artifactId>
</dependency>
<!-- Only used by the "jdbc" profile, to show what JDBC persistence actually requires. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Ask for DIRECT attestation. Register with fmt "none" and an all-zero AAGUID anyway.
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
start_app attestationdirect > /dev/null
{
header "attestation: DIRECT requested, attestation: none accepted"
ceremony register-and-login
} 2>&1 | tee "$OUTPUT_DIR/pk-attestation.txt"
stop_app

9
passkeys/scripts/bootstrap.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# The chicken-and-egg problem: a passkey cannot be a user's first credential.
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
start_app "" > /dev/null
{
header "Bootstrapping - registering a passkey requires an existing authenticated session"
ceremony bootstrap
} 2>&1 | tee "$OUTPUT_DIR/pk-bootstrap.txt"
stop_app

11
passkeys/scripts/ceremony.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# The whole thing, end to end: password login, passkey registration, logout, passkey login.
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
start_app "" > /dev/null
{
header "Registration and authentication ceremonies, driven without a browser"
echo "Spring Security 7.1.1, Spring Boot 4.1.1, rpId localhost, default settings."
echo "The authenticator is src/main/java/com/ankurm/passkeys/virtual/VirtualAuthenticator.java."
ceremony register-and-login
} 2>&1 | tee "$OUTPUT_DIR/pk-ceremony.txt"
stop_app

9
passkeys/scripts/counter.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Three assertions with an increasing signature counter, then a replay of a stale one.
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
start_app "" > /dev/null
{
header "Signature counter: stored on every assertion, compared against on none"
ceremony clone-counter
} 2>&1 | tee "$OUTPUT_DIR/pk-counter.txt"
stop_app

9
passkeys/scripts/duplicate.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# excludeCredentials, and what happens when the client ignores it.
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
start_app "" > /dev/null
{
header "Registering the same credential id twice"
ceremony duplicate
} 2>&1 | tee "$OUTPUT_DIR/pk-duplicate.txt"
stop_app

9
passkeys/scripts/filters.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Where the four WebAuthn filters and the two one-time-token filters sit in the chain.
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
start_app "" > /dev/null
{
header "The security filter chain with webAuthn() and oneTimeTokenLogin() configured"
ceremony filters
} 2>&1 | tee "$OUTPUT_DIR/pk-filters.txt"
stop_app

13
passkeys/scripts/jdbc.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Credentials in a database, using the DDL that ships inside spring-security-web.
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
start_app jdbc > /dev/null
{
header "JDBC persistence - H2, with Spring Security's own schema"
echo "schema-locations point at classpath:org/springframework/security/user-entities-schema.sql"
echo "and user-credentials-schema.sql, which live in spring-security-web, not in"
echo "spring-security-webauthn. Nothing creates these tables for you."
echo
ceremony register-and-login
} 2>&1 | tee "$OUTPUT_DIR/pk-jdbc.txt"
stop_app

63
passkeys/scripts/lib.sh Executable file
View File

@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# Shared helpers. Sourced by every script in this directory.
#
# Two traps are baked in here because both have cost real time:
#
# * never `pkill -f spring-boot` - the pattern matches the shell that is running this
# script and kills it. Kill by main class instead, which is what stop_app does.
# * `mvn -o` cannot run the Boot plugin until one online build has cached it, so the first
# run of run.sh is deliberately not offline.
set -euo pipefail
MODULE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
OUTPUT_DIR="$(cd "$MODULE_DIR/.." && pwd)/docs/output"
MAIN_CLASS="PasskeysDemoApplication"
BASE_URL="${BASE_URL:-http://localhost:8080}"
APP_LOG="${APP_LOG:-/tmp/passkeys-demo.log}"
mkdir -p "$OUTPUT_DIR"
stop_app() {
for pid in $(ps -eo pid,cmd | grep "[${MAIN_CLASS:0:1}]${MAIN_CLASS:1}" | awk '{print $1}'); do
kill -9 "$pid" 2>/dev/null || true
done
# ss -lptn sometimes reports the port with no PID, so a port-based kill silently does
# nothing and the stale process keeps serving. Wait for the port to actually close.
for _ in $(seq 1 20); do
curl -sf -o /dev/null "$BASE_URL/health" || return 0
sleep 0.5
done
}
start_app() {
local profiles="${1:-}"
stop_app
cd "$MODULE_DIR"
local args=(-B org.springframework.boot:spring-boot-maven-plugin:run)
[ -n "$profiles" ] && args+=("-Dspring-boot.run.profiles=$profiles")
setsid nohup mvn "${args[@]}" > "$APP_LOG" 2>&1 < /dev/null &
for _ in $(seq 1 90); do
curl -sf -o /dev/null "$BASE_URL/health" && { echo "started${profiles:+ with profiles: $profiles}"; return 0; }
sleep 2
done
echo "the application did not come up; see $APP_LOG" >&2
tail -40 "$APP_LOG" >&2
return 1
}
classpath() {
cd "$MODULE_DIR"
[ -f target/deps.txt ] || mvn -B -q dependency:build-classpath -Dmdep.outputFile=target/deps.txt -DincludeScope=runtime
echo "target/classes:$(cat target/deps.txt)"
}
ceremony() {
cd "$MODULE_DIR"
java --class-path "$(classpath)" tools/PasskeyCeremony.java "$@"
}
header() {
echo "=============================================================================="
echo "$1"
echo "=============================================================================="
}

15
passkeys/scripts/origin.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# What a phishing attempt looks like from the relying party's side, in both ceremonies.
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
start_app "" > /dev/null
{
header "Registration from a disallowed origin"
ceremony wrong-origin
echo
echo "--- what the server logged ---"
grep -A4 -m1 -E 'BadOriginException|InconsistentClientDataTypeException' "$APP_LOG" || tail -5 "$APP_LOG"
header "Assertion from a disallowed origin"
ceremony wrong-origin-login
} 2>&1 | tee "$OUTPUT_DIR/pk-origin.txt"
stop_app

View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# The one-time token path: generate, redeem, then try to redeem again.
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
start_app "" > /dev/null
{
header "One-time token login - the way in, and the way back after a lost device"
ceremony ott
} 2>&1 | tee "$OUTPUT_DIR/pk-ott.txt"
stop_app

13
passkeys/scripts/run-all.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Regenerates every docs/output/pk-*.txt file in this repository.
#
# Timings and instants differ between runs; nothing else should.
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
cd "$MODULE_DIR"
mvn -B -q compile
for script in ceremony counter user-verification origin attestation duplicate bootstrap step-up ott-fallback jdbc filters test-run; do
echo ">>> scripts/$script.sh"
"./scripts/$script.sh" > /dev/null
done
stop_app
ls -la "$OUTPUT_DIR"/pk-*.txt

13
passkeys/scripts/run.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Start the demo with the given profiles.
#
# ./scripts/run.sh defaults: rpId localhost, UV preferred, in memory
# ./scripts/run.sh uvrequired user verification REQUIRED on both ceremonies
# ./scripts/run.sh attestationdirect ask for DIRECT attestation and watch nothing change
# ./scripts/run.sh badorigin relying party expects an origin the client won't send
# ./scripts/run.sh jdbc credentials in H2 using Spring Security's own DDL
# ./scripts/run.sh trace every WebAuthn log line the framework emits
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
start_app "${1:-}"
echo "log: $APP_LOG"
echo "browser: $BASE_URL/login (user/password, then $BASE_URL/webauthn/register)"

9
passkeys/scripts/step-up.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# The same user, three sessions, one endpoint that only one of them can reach.
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
start_app "" > /dev/null
{
header "FactorGrantedAuthority - password, magic link and passkey are not interchangeable"
ceremony stepup
} 2>&1 | tee "$OUTPUT_DIR/pk-step-up.txt"
stop_app

5
passkeys/scripts/test-run.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# The contract tests. These need no running server.
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
cd "$MODULE_DIR"
mvn -B test 2>&1 | sed -n '/T E S T S/,$p' | tee "$OUTPUT_DIR/pk-test-run.txt"

View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# The same authenticator, with the UV flag clear, against both settings.
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
{
header "userVerification PREFERRED - the default"
start_app "" > /dev/null
ceremony no-uv
stop_app
header "userVerification REQUIRED - the uvrequired profile"
start_app uvrequired > /dev/null
ceremony no-uv
stop_app
} 2>&1 | tee "$OUTPUT_DIR/pk-user-verification.txt"

View File

@@ -0,0 +1,27 @@
/*
* Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
* repository root.
*/
package com.ankurm.passkeys;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Passkeys and WebAuthn on Spring Security 7.1 / Spring Boot 4.1.
*
* <p>Runs on port 8080 with a relying party id of {@code localhost}, because {@code localhost}
* is the one origin browsers treat as a secure context without TLS. Every other host needs
* HTTPS before {@code navigator.credentials.create()} will even be offered.
*
* <p>Nothing here is auto-configured. Spring Boot 4.1 ships no WebAuthn auto-configuration and
* no {@code spring.security.webauthn.*} properties - see docs/passkeys/01-versions.md.
*/
@SpringBootApplication
public class PasskeysDemoApplication {
public static void main(String[] args) {
SpringApplication.run(PasskeysDemoApplication.class, args);
}
}

View File

@@ -0,0 +1,44 @@
/*
* Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
* repository root.
*/
package com.ankurm.passkeys.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
/**
* Two users, in memory, with passwords - which is the first thing worth noticing about a
* passkeys demo.
*
* <p>{@code WebAuthnConfigurer.configure} throws {@code IllegalStateException: Missing
* UserDetailsService Bean} without one, and
* {@code Webauthn4JRelyingPartyOperations.createPublicKeyCredentialCreationOptions} throws
* {@code IllegalArgumentException: Authentication must be authenticated} unless the caller is
* already logged in. A passkey cannot be the first credential a user has; something else has
* to carry them to the point where they can register one. See
* docs/passkeys/06-the-bootstrap-problem.md.
*/
@Configuration
public class AppUsers {
@Bean
UserDetailsService userDetailsService() {
UserDetails user = User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.roles("USER")
.build();
UserDetails admin = User.withDefaultPasswordEncoder()
.username("admin")
.password("password")
.roles("USER", "ADMIN")
.build();
return new InMemoryUserDetailsManager(user, admin);
}
}

View File

@@ -0,0 +1,66 @@
/*
* Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
* repository root.
*/
package com.ankurm.passkeys.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.jdbc.core.JdbcOperations;
import org.springframework.security.web.webauthn.management.JdbcPublicKeyCredentialUserEntityRepository;
import org.springframework.security.web.webauthn.management.JdbcUserCredentialRepository;
import org.springframework.security.web.webauthn.management.MapPublicKeyCredentialUserEntityRepository;
import org.springframework.security.web.webauthn.management.MapUserCredentialRepository;
import org.springframework.security.web.webauthn.management.PublicKeyCredentialUserEntityRepository;
import org.springframework.security.web.webauthn.management.UserCredentialRepository;
/**
* Where credentials live.
*
* <p>{@code WebAuthnConfigurer} will happily create {@code MapUserCredentialRepository} and
* {@code MapPublicKeyCredentialUserEntityRepository} for you if no beans exist. Declaring them
* explicitly costs nothing and buys two things: the diagnostics endpoint can read them, and
* the in-memory default stops being invisible. It is in-memory - every passkey your users
* registered disappears on restart, and they have no password to fall back on.
*
* <p>The {@code jdbc} profile switches to the JDBC repositories. Their schema is not created
* for you; {@code schema-jdbc.sql} in this module is Spring Security's own DDL, loaded through
* {@code spring.sql.init}. See docs/passkeys/09-persistence.md.
*/
@Configuration
public class PersistenceConfig {
@Configuration
@Profile("!jdbc")
static class InMemory {
@Bean
PublicKeyCredentialUserEntityRepository userEntityRepository() {
return new MapPublicKeyCredentialUserEntityRepository();
}
@Bean
UserCredentialRepository userCredentialRepository() {
return new MapUserCredentialRepository();
}
}
@Configuration
@Profile("jdbc")
static class Jdbc {
@Bean
PublicKeyCredentialUserEntityRepository userEntityRepository(JdbcOperations jdbc) {
return new JdbcPublicKeyCredentialUserEntityRepository(jdbc);
}
@Bean
UserCredentialRepository userCredentialRepository(JdbcOperations jdbc) {
return new JdbcUserCredentialRepository(jdbc);
}
}
}

View File

@@ -0,0 +1,90 @@
/*
* Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
* repository root.
*/
package com.ankurm.passkeys.config;
import java.util.Set;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.web.webauthn.api.AttestationConveyancePreference;
import org.springframework.security.web.webauthn.api.AuthenticatorSelectionCriteria;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialRpEntity;
import org.springframework.security.web.webauthn.api.ResidentKeyRequirement;
import org.springframework.security.web.webauthn.api.UserVerificationRequirement;
import org.springframework.security.web.webauthn.management.PublicKeyCredentialUserEntityRepository;
import org.springframework.security.web.webauthn.management.UserCredentialRepository;
import org.springframework.security.web.webauthn.management.WebAuthnRelyingPartyOperations;
import org.springframework.security.web.webauthn.management.Webauthn4JRelyingPartyOperations;
/**
* The relying party operations bean, built by hand so the defaults can be changed one at a
* time and the difference observed.
*
* <p>Exposing this bean at all is a decision with a consequence: from that point on the
* {@code rpId}, {@code rpName} and {@code allowedOrigins} in {@link SecurityConfig} are dead
* configuration. {@code WebAuthnConfigurer.webAuthnRelyingPartyOperations} looks for a bean
* of this type first and, when it finds one, never reads its own fields. That is why the
* values are repeated here rather than shared.
*
* <p>Profiles:
*
* <ul>
* <li><b>uvrequired</b> - raises user verification from the default {@code PREFERRED} to
* {@code REQUIRED}, on both ceremonies. The default accepts a credential created and asserted
* with the UV flag clear, which means "something was touched" rather than "someone was
* verified".</li>
* <li><b>attestationdirect</b> - asks for {@code DIRECT} attestation instead of the default
* {@code NONE}. Registration still succeeds against an authenticator that sends
* {@code fmt: "none"} and an all-zero AAGUID, because the default
* {@code WebAuthnManager.createNonStrictWebAuthnManager()} verifies no attestation at all.
* Asking is not checking.</li>
* <li><b>badorigin</b> - the same relying party, told to expect a different origin. This is
* what a phishing attempt looks like from the server's side.</li>
* </ul>
*
* @see <a href="https://ankurm.com/git.app/asmhatre/spring-auth-demo/src/branch/main/docs/passkeys/05-defaults.md">docs/passkeys/05-defaults.md</a>
*/
@Configuration
@Profile({ "uvrequired", "attestationdirect", "badorigin" })
public class RelyingPartyConfig {
@Bean
WebAuthnRelyingPartyOperations relyingPartyOperations(PublicKeyCredentialUserEntityRepository userEntities,
UserCredentialRepository userCredentials, @Value("${demo.rp-id:localhost}") String rpId,
@Value("${demo.allowed-origin:http://localhost:8080}") String allowedOrigin,
@Value("${demo.user-verification-required:false}") boolean userVerificationRequired,
@Value("${demo.attestation-direct:false}") boolean attestationDirect) {
PublicKeyCredentialRpEntity rp = PublicKeyCredentialRpEntity.builder()
.id(rpId)
.name("ankurm passkeys demo")
.build();
Webauthn4JRelyingPartyOperations operations = new Webauthn4JRelyingPartyOperations(userEntities,
userCredentials, rp, Set.of(allowedOrigin));
if (userVerificationRequired) {
// Both halves have to be set. registerCredential() reads userVerification off the
// creation options; authenticate() reads it off the request options. Setting only
// one leaves the other ceremony at PREFERRED, which verifies nothing.
operations.setCustomizeCreationOptions((options) -> options
.authenticatorSelection(AuthenticatorSelectionCriteria.builder()
.userVerification(UserVerificationRequirement.REQUIRED)
.residentKey(ResidentKeyRequirement.REQUIRED)
.build()));
operations.setCustomizeRequestOptions(
(options) -> options.userVerification(UserVerificationRequirement.REQUIRED));
}
if (attestationDirect) {
operations
.setCustomizeCreationOptions((options) -> options.attestation(AttestationConveyancePreference.DIRECT));
}
return operations;
}
}

View File

@@ -0,0 +1,78 @@
/*
* Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
* repository root.
*/
package com.ankurm.passkeys.config;
import com.ankurm.passkeys.ott.ConsoleOneTimeTokenHandler;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
/**
* The whole passkey configuration is the {@code webAuthn(..)} block. Everything else is
* scaffolding.
*
* <p>Three things about this file are worth more than they look:
*
* <ul>
* <li>{@code rpId} and {@code allowedOrigins} are two separate settings that must agree. The
* rpId is the domain the credential is scoped to; the origin is the exact scheme, host and
* port the browser reports. {@code localhost} and {@code http://localhost:8080} agree.
* {@code localhost} and {@code http://127.0.0.1:8080} do not, and the failure arrives as a
* flat 401 - see docs/passkeys/07-failure-modes.md.</li>
* <li>If a {@code WebAuthnRelyingPartyOperations} bean exists, the {@code rpId},
* {@code rpName} and {@code allowedOrigins} set here are silently ignored:
* {@code WebAuthnConfigurer.webAuthnRelyingPartyOperations} returns the bean and never reads
* its own fields. {@link RelyingPartyConfig} exposes such a bean under several profiles,
* which is precisely why the values are repeated there.</li>
* <li>{@code oneTimeTokenLogin} is not decoration. Registering a passkey requires an existing
* authenticated session, so a passwordless system still needs a way in and a way back after a
* lost device.</li>
* </ul>
*
* @see <a href="https://ankurm.com/git.app/asmhatre/spring-auth-demo/src/branch/main/docs/passkeys/02-minimum-configuration.md">docs/passkeys/02-minimum-configuration.md</a>
*/
@Configuration
public class SecurityConfig {
private final String rpId;
private final String allowedOrigin;
public SecurityConfig(@Value("${demo.rp-id:localhost}") String rpId,
@Value("${demo.allowed-origin:http://localhost:8080}") String allowedOrigin) {
this.rpId = rpId;
this.allowedOrigin = allowedOrigin;
}
@Bean
SecurityFilterChain filterChain(HttpSecurity http, ConsoleOneTimeTokenHandler oneTimeTokenHandler)
throws Exception {
http.authorizeHttpRequests((requests) -> requests.requestMatchers("/", "/health")
.permitAll()
// Step-up. A session established by a magic link carries FACTOR_OTT; one
// established by a passkey carries FACTOR_WEBAUTHN. Both are ordinary
// authorities, so requiring a real passkey for a sensitive endpoint is one
// matcher - see docs/passkeys/06-the-bootstrap-problem.md.
.requestMatchers("/passkey-only")
.hasAuthority("FACTOR_WEBAUTHN")
.anyRequest()
.authenticated())
.formLogin(Customizer.withDefaults())
// The magic-link fallback. Without a OneTimeTokenGenerationSuccessHandler the
// context fails to start - Spring Security refuses to guess how to deliver a
// token. See docs/passkeys/08-one-time-token-fallback.md.
.oneTimeTokenLogin((ott) -> ott.tokenGenerationSuccessHandler(oneTimeTokenHandler))
.webAuthn((webAuthn) -> webAuthn.rpId(this.rpId)
.rpName("ankurm passkeys demo")
.allowedOrigins(this.allowedOrigin))
.logout(Customizer.withDefaults());
return http.build();
}
}

View File

@@ -0,0 +1,105 @@
/*
* Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
* repository root.
*/
package com.ankurm.passkeys.diag;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import jakarta.servlet.Filter;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.FilterChainProxy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.webauthn.api.AuthenticatorTransport;
import org.springframework.security.web.webauthn.api.CredentialRecord;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity;
import org.springframework.security.web.webauthn.management.PublicKeyCredentialUserEntityRepository;
import org.springframework.security.web.webauthn.management.UserCredentialRepository;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Prints the state that is otherwise invisible: what a stored {@code CredentialRecord}
* actually contains, and where the WebAuthn filters sit in the chain.
*
* <p>{@code /diag/credentials} is the exhibit for the signature counter. The
* {@code signatureCount} it reports is the value the relying party persisted after the last
* assertion - which is not the value it compares the next assertion against. See
* docs/passkeys/10-signature-counter.md.
*
* <p>Delete this class before shipping. It reports credential ids and user handles to any
* authenticated caller.
*/
@RestController
public class CredentialDiagnostics {
private final PublicKeyCredentialUserEntityRepository userEntities;
private final UserCredentialRepository userCredentials;
private final FilterChainProxy filterChainProxy;
public CredentialDiagnostics(PublicKeyCredentialUserEntityRepository userEntities,
UserCredentialRepository userCredentials, FilterChainProxy filterChainProxy) {
this.userEntities = userEntities;
this.userCredentials = userCredentials;
this.filterChainProxy = filterChainProxy;
}
@GetMapping("/diag/credentials")
public Map<String, Object> credentials(Authentication authentication) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("principal", authentication.getName());
result.put("principalType", authentication.getClass().getSimpleName());
result.put("authorities", authentication.getAuthorities().stream().map(Object::toString).sorted().toList());
PublicKeyCredentialUserEntity userEntity = this.userEntities.findByUsername(authentication.getName());
if (userEntity == null) {
result.put("userEntity", null);
result.put("credentials", List.of());
return result;
}
result.put("userEntity", Map.of("id", userEntity.getId().toBase64UrlString(), "name", userEntity.getName(),
"displayName", userEntity.getDisplayName()));
List<Map<String, Object>> records = new ArrayList<>();
for (CredentialRecord record : this.userCredentials.findByUserId(userEntity.getId())) {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("label", record.getLabel());
entry.put("credentialId", record.getCredentialId().toBase64UrlString());
entry.put("signatureCount", record.getSignatureCount());
entry.put("uvInitialized", record.isUvInitialized());
entry.put("backupEligible", record.isBackupEligible());
entry.put("backupState", record.isBackupState());
entry.put("transports", record.getTransports().stream().map(AuthenticatorTransport::getValue).sorted().toList());
entry.put("attestationObjectBytes",
(record.getAttestationObject() != null) ? record.getAttestationObject().getBytes().length : null);
entry.put("created", String.valueOf(record.getCreated()));
entry.put("lastUsed", String.valueOf(record.getLastUsed()));
records.add(entry);
}
result.put("credentials", records);
return result;
}
@GetMapping("/diag/filters")
public Map<String, Object> filters() {
Map<String, Object> result = new LinkedHashMap<>();
List<Map<String, Object>> chains = new ArrayList<>();
for (SecurityFilterChain chain : this.filterChainProxy.getFilterChains()) {
List<String> names = new ArrayList<>();
int position = 0;
for (Filter filter : chain.getFilters()) {
names.add("%2d %s".formatted(++position, filter.getClass().getSimpleName()));
}
chains.add(Map.of("matcher", String.valueOf(chain), "filters", names));
}
result.put("chains", chains);
return result;
}
}

View File

@@ -0,0 +1,67 @@
/*
* Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
* repository root.
*/
package com.ankurm.passkeys.ott;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.ott.OneTimeToken;
import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler;
import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.util.UriComponentsBuilder;
/**
* Delivers the one-time token by printing it, and by writing it to a file the demo scripts
* read.
*
* <p>In production this is where you send an email or an SMS. Spring Security deliberately has
* no default: {@code OneTimeTokenLoginConfigurer} throws at startup rather than guess a
* delivery channel, which is the correct decision and also the most common first error - see
* docs/passkeys/08-one-time-token-fallback.md.
*
* <p>The important behaviour here is what happens for a username that does not exist. The
* token is still generated, the file is still written, and the browser still lands on
* {@code /login/ott}. {@code InMemoryOneTimeTokenService} has no {@code UserDetailsService}
* and cannot tell; the failure surfaces later, in
* {@code OneTimeTokenAuthenticationProvider}, when the token is redeemed. The account
* enumeration oracle that a "no such user" response would create is closed by accident rather
* than by design, but it is closed.
*
* <p>{@code TOKEN_FILE} is a demo affordance. Deleting it is the first thing you should do
* with this class.
*/
@Component
public class ConsoleOneTimeTokenHandler implements OneTimeTokenGenerationSuccessHandler {
/** Where {@code scripts/ott-fallback.sh} picks the token up. */
public static final Path TOKEN_FILE = Path.of(System.getProperty("java.io.tmpdir"), "passkeys-demo-ott.txt");
private final OneTimeTokenGenerationSuccessHandler redirect = new RedirectOneTimeTokenGenerationSuccessHandler(
"/login/ott");
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, OneTimeToken oneTimeToken)
throws IOException, ServletException {
String link = UriComponentsBuilder.fromUriString(request.getRequestURL().toString())
.replacePath(request.getContextPath())
.replaceQuery(null)
.fragment(null)
.path("/login/ott")
.queryParam("token", oneTimeToken.getTokenValue())
.toUriString();
System.out.printf("%n[one-time-token] username=%s expires=%s%n[one-time-token] %s%n%n",
oneTimeToken.getUsername(), oneTimeToken.getExpiresAt(), link);
Files.writeString(TOKEN_FILE, oneTimeToken.getTokenValue(), StandardCharsets.UTF_8);
this.redirect.handle(request, response, oneTimeToken);
}
}

View File

@@ -0,0 +1,87 @@
/*
* Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
* repository root.
*/
package com.ankurm.passkeys.virtual;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
/**
* The smallest CBOR encoder that can produce a WebAuthn attestation object.
*
* <p>An authenticator emits CBOR for exactly two things: the COSE public key inside attested
* credential data, and the attestation object that wraps it. Both are fixed-shape maps of a
* handful of entries, so the code below is enough - there is no reason to pull a CBOR library
* onto the authenticator side, and writing the bytes by hand keeps the structure visible
* instead of hiding it behind a serializer.
*
* <p>Only the four major types WebAuthn needs are implemented: unsigned integers (major 0),
* negative integers (major 1), byte strings (major 2), text strings (major 3) and maps
* (major 5). Everything is written in the shortest form, which is what canonical CBOR
* requires anyway.
*
* @see <a href="https://www.rfc-editor.org/rfc/rfc8949">RFC 8949 - Concise Binary Object
* Representation</a>
* @see VirtualAuthenticator
* @see <a href="https://ankurm.com/git.app/asmhatre/spring-auth-demo/src/branch/main/docs/passkeys/04-virtual-authenticator.md">docs/passkeys/04-virtual-authenticator.md</a>
*/
public final class Cbor {
private final ByteArrayOutputStream out = new ByteArrayOutputStream();
/** Writes the initial byte plus whatever length bytes the argument needs. */
private Cbor head(int major, long value) {
int m = major << 5;
if (value < 24) {
this.out.write(m | (int) value);
}
else if (value < 256) {
this.out.write(m | 24);
this.out.write((int) value);
}
else if (value < 65536) {
this.out.write(m | 25);
this.out.write((int) (value >> 8) & 0xFF);
this.out.write((int) value & 0xFF);
}
else {
this.out.write(m | 26);
this.out.write((int) (value >> 24) & 0xFF);
this.out.write((int) (value >> 16) & 0xFF);
this.out.write((int) (value >> 8) & 0xFF);
this.out.write((int) value & 0xFF);
}
return this;
}
/** Starts a definite-length map with {@code entries} key/value pairs. */
public Cbor map(int entries) {
return head(5, entries);
}
/** An integer key or value. Negative values use CBOR major type 1. */
public Cbor num(long value) {
return (value >= 0) ? head(0, value) : head(1, -1 - value);
}
/** A text string - the attestation object's keys are text, the COSE key's are integers. */
public Cbor text(String value) {
byte[] utf8 = value.getBytes(StandardCharsets.UTF_8);
head(3, utf8.length);
this.out.writeBytes(utf8);
return this;
}
/** A byte string - authenticator data, and the COSE key coordinates. */
public Cbor bytes(byte[] value) {
head(2, value.length);
this.out.writeBytes(value);
return this;
}
public byte[] toByteArray() {
return this.out.toByteArray();
}
}

View File

@@ -0,0 +1,291 @@
/*
* Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
* repository root.
*/
package com.ankurm.passkeys.virtual;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.MessageDigest;
import java.security.Signature;
import java.security.interfaces.ECPublicKey;
import java.security.spec.ECGenParameterSpec;
import java.util.Base64;
/**
* A software authenticator: everything a security key or a phone does, in about two hundred
* lines, with no browser and no hardware.
*
* <p>This exists so the ceremonies in this module can be executed rather than described.
* {@link #makeCredential} produces a real CBOR attestation object with a real COSE P-256
* public key inside it, and {@link #getAssertion} produces a real ECDSA-SHA256 signature over
* the concatenation the specification requires. Spring Security's
* {@code Webauthn4JRelyingPartyOperations} - and WebAuthn4J underneath it - verifies both
* without knowing or caring that no hardware was involved.
*
* <p>What it deliberately does <em>not</em> do is any of the things that make a real
* authenticator a security boundary: there is no user presence test, no user verification, no
* secure element, and the private key sits in the heap. It sets the UP, UV, BE and BS flags
* because it is asked to, not because anything happened. That is exactly why it is useful for
* showing which of those flags the relying party actually checks.
*
* <p>The signature counter is a field you control, which is the point of
* {@code scripts/clone-counter.sh}.
*
* @see <a href="https://www.w3.org/TR/webauthn-3/#sctn-authenticator-data">WebAuthn Level 3,
* section 6.1 - Authenticator Data</a>
* @see <a href="https://ankurm.com/git.app/asmhatre/spring-auth-demo/src/branch/main/docs/passkeys/04-virtual-authenticator.md">docs/passkeys/04-virtual-authenticator.md</a>
*/
public final class VirtualAuthenticator {
/** User Present. Set when the authenticator believes a human touched it. */
public static final int FLAG_UP = 0x01;
/** User Verified. Set when a PIN or biometric was checked, not merely a touch. */
public static final int FLAG_UV = 0x04;
/** Backup Eligible. Set by syncable passkeys - a phone or a password manager. */
public static final int FLAG_BE = 0x08;
/** Backup State. Set when the credential is currently synced to a backup. */
public static final int FLAG_BS = 0x10;
/** Attested Credential Data included. Set during registration, never during assertion. */
public static final int FLAG_AT = 0x40;
private static final Base64.Encoder B64URL = Base64.getUrlEncoder().withoutPadding();
private static final Base64.Decoder B64URL_DEC = Base64.getUrlDecoder();
private final KeyPair keyPair;
private final byte[] credentialId;
private final byte[] aaguid;
private int flags = FLAG_UP | FLAG_UV | FLAG_BE | FLAG_BS;
private long signCount;
public VirtualAuthenticator() {
this(randomBytes(16), new byte[16], 0);
}
/**
* @param credentialId the credential id this authenticator will hand out
* @param aaguid the authenticator model identifier; all-zero means "not disclosed", which
* is what platform authenticators send when attestation is {@code none}
* @param initialSignCount the starting value of the signature counter. Real platform
* authenticators (Touch ID, Windows Hello, iCloud Keychain) leave this at zero forever
*/
public VirtualAuthenticator(byte[] credentialId, byte[] aaguid, long initialSignCount) {
this.credentialId = credentialId;
this.aaguid = aaguid;
this.signCount = initialSignCount;
try {
KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
generator.initialize(new ECGenParameterSpec("secp256r1"));
this.keyPair = generator.generateKeyPair();
}
catch (Exception ex) {
throw new IllegalStateException("cannot generate a P-256 key pair", ex);
}
}
/** Overrides the flag byte, so a caller can withhold UV or BE and see what breaks. */
public VirtualAuthenticator flags(int flags) {
this.flags = flags;
return this;
}
/** Sets the signature counter used by the next assertion. */
public VirtualAuthenticator signCount(long signCount) {
this.signCount = signCount;
return this;
}
public long signCount() {
return this.signCount;
}
public String credentialIdBase64Url() {
return B64URL.encodeToString(this.credentialId);
}
/**
* The registration half of the ceremony: {@code navigator.credentials.create()}.
* @param rpId the relying party id, hashed into authenticator data
* @param origin the origin that will appear in client data - the relying party compares
* it against its allowed origins, and a mismatch is the whole phishing defence
* @param challengeBase64Url the challenge from
* {@code POST /webauthn/register/options}, still base64url encoded
* @return the attestation object and client data, base64url encoded, ready to post
*/
public Registration makeCredential(String rpId, String origin, String challengeBase64Url) {
byte[] clientDataJson = clientData("webauthn.create", origin, challengeBase64Url);
byte[] authData = authenticatorData(rpId, this.flags | FLAG_AT, this.signCount, attestedCredentialData());
// "none" attestation: an empty statement. Platform authenticators send this, and it
// is what Spring Security asks for by default (AttestationConveyancePreference.NONE).
byte[] attestationObject = new Cbor().map(3)
.text("fmt")
.text("none")
.text("attStmt")
.map(0)
.text("authData")
.bytes(authData)
.toByteArray();
return new Registration(B64URL.encodeToString(this.credentialId), B64URL.encodeToString(attestationObject),
B64URL.encodeToString(clientDataJson));
}
/**
* The authentication half of the ceremony: {@code navigator.credentials.get()}.
*
* <p>The counter is incremented before signing, which is what a hardware key does. Pass an
* explicit value to {@link #signCount(long)} first to replay an old one.
* @param rpId the relying party id
* @param origin the origin placed in client data
* @param challengeBase64Url the challenge from {@code POST /webauthn/authenticate/options}
* @param userHandleBase64Url the user handle to return, or null to omit it
* @return authenticator data, client data and the assertion signature, base64url encoded
*/
public Assertion getAssertion(String rpId, String origin, String challengeBase64Url, String userHandleBase64Url) {
byte[] clientDataJson = clientData("webauthn.get", origin, challengeBase64Url);
// No FLAG_AT: attested credential data is registration-only.
byte[] authData = authenticatorData(rpId, this.flags, this.signCount, new byte[0]);
byte[] clientDataHash = sha256(clientDataJson);
byte[] signed = concat(authData, clientDataHash);
byte[] signature;
try {
Signature ecdsa = Signature.getInstance("SHA256withECDSA");
ecdsa.initSign(this.keyPair.getPrivate());
ecdsa.update(signed);
signature = ecdsa.sign();
}
catch (Exception ex) {
throw new IllegalStateException("cannot sign the assertion", ex);
}
return new Assertion(B64URL.encodeToString(this.credentialId), B64URL.encodeToString(authData),
B64URL.encodeToString(clientDataJson), B64URL.encodeToString(signature), userHandleBase64Url);
}
/** Increments the counter the way a hardware key does, and returns the new value. */
public long tick() {
return ++this.signCount;
}
/**
* {@code rpIdHash || flags || signCount || attestedCredentialData}. Fixed layout, big
* endian counter, no padding: this is the buffer the signature is computed over.
*/
private byte[] authenticatorData(String rpId, int flagByte, long counter, byte[] attested) {
byte[] rpIdHash = sha256(rpId.getBytes(StandardCharsets.UTF_8));
ByteBuffer buffer = ByteBuffer.allocate(32 + 1 + 4 + attested.length);
buffer.put(rpIdHash);
buffer.put((byte) flagByte);
buffer.putInt((int) counter);
buffer.put(attested);
return buffer.array();
}
/** {@code aaguid || credentialIdLength || credentialId || COSE public key}. */
private byte[] attestedCredentialData() {
byte[] cose = cosePublicKey();
ByteBuffer buffer = ByteBuffer.allocate(16 + 2 + this.credentialId.length + cose.length);
buffer.put(this.aaguid);
buffer.putShort((short) this.credentialId.length);
buffer.put(this.credentialId);
buffer.put(cose);
return buffer.array();
}
/**
* A COSE_Key for ES256: kty=EC2(2), alg=-7, crv=P-256(1), and the two 32-byte affine
* coordinates. The coordinates must be left-padded to exactly 32 bytes - a BigInteger
* whose top byte happens to be zero will otherwise serialise short and the relying party
* will reject the key.
*/
private byte[] cosePublicKey() {
ECPublicKey publicKey = (ECPublicKey) this.keyPair.getPublic();
byte[] x = fixedLength(publicKey.getW().getAffineX().toByteArray(), 32);
byte[] y = fixedLength(publicKey.getW().getAffineY().toByteArray(), 32);
return 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)
.num(-3)
.bytes(y)
.toByteArray();
}
private static byte[] clientData(String type, String origin, String challengeBase64Url) {
String json = "{\"type\":\"" + type + "\",\"challenge\":\"" + challengeBase64Url + "\",\"origin\":\"" + origin
+ "\",\"crossOrigin\":false}";
return json.getBytes(StandardCharsets.UTF_8);
}
/**
* Strips a BigInteger's sign byte or left-pads a short magnitude, so the result is exactly
* {@code length} bytes.
*/
private static byte[] fixedLength(byte[] value, int length) {
if (value.length == length) {
return value;
}
byte[] result = new byte[length];
if (value.length > length) {
System.arraycopy(value, value.length - length, result, 0, length);
}
else {
System.arraycopy(value, 0, result, length - value.length, value.length);
}
return result;
}
private static byte[] sha256(byte[] input) {
try {
return MessageDigest.getInstance("SHA-256").digest(input);
}
catch (Exception ex) {
throw new IllegalStateException("SHA-256 is unavailable", ex);
}
}
private static byte[] concat(byte[] first, byte[] second) {
byte[] result = new byte[first.length + second.length];
System.arraycopy(first, 0, result, 0, first.length);
System.arraycopy(second, 0, result, first.length, second.length);
return result;
}
private static byte[] randomBytes(int length) {
byte[] bytes = new byte[length];
new java.security.SecureRandom().nextBytes(bytes);
return bytes;
}
public static byte[] decodeBase64Url(String value) {
return B64URL_DEC.decode(value);
}
public static String encodeBase64Url(byte[] value) {
return B64URL.encodeToString(value);
}
/** What {@code navigator.credentials.create()} hands back, already base64url encoded. */
public record Registration(String credentialId, String attestationObject, String clientDataJson) {
}
/** What {@code navigator.credentials.get()} hands back, already base64url encoded. */
public record Assertion(String credentialId, String authenticatorData, String clientDataJson, String signature,
String userHandle) {
}
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
* repository root.
*/
package com.ankurm.passkeys.web;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/** Two endpoints: one anyone can reach, one that proves a login happened. */
@RestController
public class ApiControllers {
@GetMapping("/health")
public Map<String, String> health() {
return Map.of("status", "UP");
}
/**
* Reports which factors the current session actually carries. A passkey login adds
* {@code FACTOR_WEBAUTHN}; a one-time token login adds {@code FACTOR_OTT}; a password
* login adds {@code FACTOR_PASSWORD}. That is how Spring Security 7 expresses
* multi-factor state, and it is why the same endpoint can tell the three apart.
*/
/**
* Reachable only by a session that authenticated with a passkey. A one-time-token session
* is authenticated and still gets 403, which is the whole point of step-up.
*/
@GetMapping("/passkey-only")
public Map<String, String> passkeyOnly() {
return Map.of("ok", "this endpoint required FACTOR_WEBAUTHN");
}
@GetMapping("/me")
public Map<String, Object> me(Authentication authentication) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("name", authentication.getName());
result.put("authenticationType", authentication.getClass().getSimpleName());
result.put("authorities", authentication.getAuthorities().stream().map(Object::toString).sorted().toList());
return result;
}
}

View File

@@ -0,0 +1,7 @@
# Ask the authenticator for DIRECT attestation instead of the default NONE.
#
# Registration still succeeds against an authenticator that answers with fmt "none" and an
# all-zero AAGUID, because the default WebAuthnManager verifies no attestation statement at
# all. Asking is not checking.
demo:
attestation-direct: true

View File

@@ -0,0 +1,4 @@
# The relying party expects an origin the client will not send. This is the server side of a
# phishing attempt: same rpId, wrong origin, ceremony refused.
demo:
allowed-origin: https://passkeys.example.com

View File

@@ -0,0 +1,18 @@
# JDBC persistence for credentials and user entities.
#
# The two DDL files below are Spring Security's own. They ship inside spring-security-web,
# not spring-security-webauthn - the 7.0 artifact split moved the classes and left the SQL
# and the JavaScript behind. Nothing creates these tables for you.
spring:
autoconfigure:
exclude: []
datasource:
url: jdbc:h2:mem:passkeys;DB_CLOSE_DELAY=-1
username: sa
password:
sql:
init:
mode: always
schema-locations:
- classpath:org/springframework/security/user-entities-schema.sql
- classpath:org/springframework/security/user-credentials-schema.sql

View File

@@ -0,0 +1,10 @@
# Everything the WebAuthn and one-time-token machinery has to say.
#
# WebAuthnAuthenticationProvider collapses every failure into BadCredentialsException, so the
# HTTP response is a bare 401 with no body. The reason is only ever visible in the log, and
# only at DEBUG. Start here when a ceremony fails.
logging:
level:
org.springframework.security: DEBUG
org.springframework.security.web.webauthn: TRACE
com.webauthn4j: DEBUG

View File

@@ -0,0 +1,3 @@
# User verification promoted from PREFERRED (the default) to REQUIRED, on both ceremonies.
demo:
user-verification-required: true

View File

@@ -0,0 +1,24 @@
# Defaults: rpId "localhost", origin http://localhost:8080, in-memory credentials.
#
# localhost is the only host a browser treats as a secure context without TLS, which is why
# every WebAuthn tutorial uses it and why every WebAuthn deployment then breaks on the first
# real hostname. See docs/passkeys/07-failure-modes.md.
server:
port: 8080
demo:
rp-id: localhost
allowed-origin: http://localhost:8080
spring:
application:
name: passkeys-demo
# No datasource is needed unless the jdbc profile is active; the H2 auto-configuration is
# switched off here so the default profile starts without one.
autoconfigure:
exclude:
- org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration
logging:
level:
org.springframework.security.web.webauthn: INFO

View File

@@ -0,0 +1,246 @@
/*
* Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
* repository root.
*/
package com.ankurm.passkeys;
import java.util.Set;
import com.ankurm.passkeys.virtual.VirtualAuthenticator;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.web.webauthn.api.AuthenticatorAssertionResponse;
import org.springframework.security.web.webauthn.api.AuthenticatorAttestationResponse;
import org.springframework.security.web.webauthn.api.AuthenticatorSelectionCriteria;
import org.springframework.security.web.webauthn.api.AuthenticatorTransport;
import org.springframework.security.web.webauthn.api.Bytes;
import org.springframework.security.web.webauthn.api.CredentialRecord;
import org.springframework.security.web.webauthn.api.PublicKeyCredential;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialCreationOptions;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialRequestOptions;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialRpEntity;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialType;
import org.springframework.security.web.webauthn.api.ResidentKeyRequirement;
import org.springframework.security.web.webauthn.api.UserVerificationRequirement;
import org.springframework.security.web.webauthn.management.ImmutablePublicKeyCredentialCreationOptionsRequest;
import org.springframework.security.web.webauthn.management.ImmutablePublicKeyCredentialRequestOptionsRequest;
import org.springframework.security.web.webauthn.management.ImmutableRelyingPartyRegistrationRequest;
import org.springframework.security.web.webauthn.management.MapPublicKeyCredentialUserEntityRepository;
import org.springframework.security.web.webauthn.management.MapUserCredentialRepository;
import org.springframework.security.web.webauthn.management.PublicKeyCredentialUserEntityRepository;
import org.springframework.security.web.webauthn.management.RelyingPartyAuthenticationRequest;
import org.springframework.security.web.webauthn.management.RelyingPartyPublicKey;
import org.springframework.security.web.webauthn.management.UserCredentialRepository;
import org.springframework.security.web.webauthn.management.Webauthn4JRelyingPartyOperations;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Contract tests for the relying party operations, driven by a software authenticator.
*
* <p>These pin behaviour rather than the happy path. Several of them assert that something
* <em>is not</em> checked, which is the only honest way to record a default: if a later Spring
* Security release starts enforcing it, the test fails and the article needs updating.
*
* <p>No HTTP, no browser and no Spring context - the ceremony is exercised straight through
* {@code Webauthn4JRelyingPartyOperations}, so a failure here is the framework's behaviour and
* not a filter-ordering accident.
*
* @see <a href="https://ankurm.com/git.app/asmhatre/spring-auth-demo/src/branch/main/docs/passkeys/05-defaults.md">docs/passkeys/05-defaults.md</a>
*/
class PasskeyContractTests {
private static final String RP_ID = "localhost";
private static final String ORIGIN = "http://localhost:8080";
private PublicKeyCredentialUserEntityRepository userEntities;
private UserCredentialRepository userCredentials;
private Webauthn4JRelyingPartyOperations operations;
private final Authentication authentication = UsernamePasswordAuthenticationToken.authenticated("user", null,
AuthorityUtils.createAuthorityList("ROLE_USER"));
@BeforeEach
void setUp() {
this.userEntities = new MapPublicKeyCredentialUserEntityRepository();
this.userCredentials = new MapUserCredentialRepository();
this.operations = newOperations();
}
private Webauthn4JRelyingPartyOperations newOperations() {
return new Webauthn4JRelyingPartyOperations(this.userEntities, this.userCredentials,
PublicKeyCredentialRpEntity.builder().id(RP_ID).name("test").build(), Set.of(ORIGIN));
}
@Test
@DisplayName("a software authenticator completes both ceremonies")
void bothCeremoniesSucceed() {
VirtualAuthenticator authenticator = new VirtualAuthenticator();
CredentialRecord record = register(authenticator, ORIGIN);
assertThat(record.getCredentialId().toBase64UrlString()).isEqualTo(authenticator.credentialIdBase64Url());
assertThat(record.getSignatureCount()).isZero();
assertThat(record.isUvInitialized()).isTrue();
authenticator.signCount(1);
assertThat(authenticate(authenticator, ORIGIN).getName()).isEqualTo("user");
}
@Test
@DisplayName("the signature counter is persisted on every assertion and compared against on none")
void signatureCounterIsStoredButNotVerified() {
VirtualAuthenticator authenticator = new VirtualAuthenticator();
CredentialRecord record = register(authenticator, ORIGIN);
Bytes credentialId = record.getCredentialId();
authenticator.signCount(1);
authenticate(authenticator, ORIGIN);
assertThat(this.userCredentials.findByCredentialId(credentialId).getSignatureCount()).isEqualTo(1);
authenticator.signCount(2);
authenticate(authenticator, ORIGIN);
assertThat(this.userCredentials.findByCredentialId(credentialId).getSignatureCount()).isEqualTo(2);
// A cloned authenticator would present a counter it had already used. The relying
// party has stored 2. It accepts 1 anyway, because authenticate() rebuilds the
// WebAuthn4J credential record from the stored attestation object, whose counter is
// frozen at its registration value of 0 - the persisted signatureCount above is never
// read back. If this assertion ever starts failing, clone detection has been fixed.
authenticator.signCount(1);
assertThatCode(() -> authenticate(authenticator, ORIGIN)).doesNotThrowAnyException();
assertThat(this.userCredentials.findByCredentialId(credentialId).getSignatureCount()).isEqualTo(1);
}
@Test
@DisplayName("user verification is not required by default, and the UV flag is recorded either way")
void userVerificationIsPreferredNotRequired() {
VirtualAuthenticator authenticator = new VirtualAuthenticator()
.flags(VirtualAuthenticator.FLAG_UP | VirtualAuthenticator.FLAG_BE | VirtualAuthenticator.FLAG_BS);
CredentialRecord record = register(authenticator, ORIGIN);
assertThat(record.isUvInitialized()).isFalse();
authenticator.signCount(1);
assertThatCode(() -> authenticate(authenticator, ORIGIN)).doesNotThrowAnyException();
}
@Test
@DisplayName("userVerification REQUIRED refuses a registration whose UV flag is clear")
void userVerificationRequiredIsEnforcedAtRegistration() {
this.operations = newOperations();
this.operations.setCustomizeCreationOptions((options) -> options
.authenticatorSelection(AuthenticatorSelectionCriteria.builder()
.userVerification(UserVerificationRequirement.REQUIRED)
.residentKey(ResidentKeyRequirement.REQUIRED)
.build()));
VirtualAuthenticator authenticator = new VirtualAuthenticator()
.flags(VirtualAuthenticator.FLAG_UP | VirtualAuthenticator.FLAG_BE | VirtualAuthenticator.FLAG_BS);
assertThatThrownBy(() -> register(authenticator, ORIGIN))
.isInstanceOf(com.webauthn4j.verifier.exception.UserNotVerifiedException.class)
.hasMessageContaining("UV flag in authenticatorData is not set");
}
@Test
@DisplayName("an origin the relying party did not allow is rejected in both ceremonies")
void originIsChecked() {
VirtualAuthenticator authenticator = new VirtualAuthenticator();
assertThatThrownBy(() -> register(authenticator, "http://evil.localhost:8080"))
.isInstanceOf(com.webauthn4j.verifier.exception.BadOriginException.class);
register(authenticator, ORIGIN);
authenticator.signCount(1);
assertThatThrownBy(() -> authenticate(authenticator, "http://evil.localhost:8080"))
.isInstanceOf(com.webauthn4j.verifier.exception.BadOriginException.class);
}
@Test
@DisplayName("asking for DIRECT attestation does not make anything verify attestation")
void directAttestationIsRequestedNotVerified() {
this.operations = newOperations();
this.operations.setCustomizeCreationOptions((options) -> options
.attestation(org.springframework.security.web.webauthn.api.AttestationConveyancePreference.DIRECT));
PublicKeyCredentialCreationOptions creationOptions = this.operations
.createPublicKeyCredentialCreationOptions(new ImmutablePublicKeyCredentialCreationOptionsRequest(
this.authentication));
assertThat(creationOptions.getAttestation().getValue()).isEqualTo("direct");
// The authenticator answers "none" with an all-zero AAGUID. It is registered anyway.
VirtualAuthenticator authenticator = new VirtualAuthenticator();
assertThatCode(() -> registerWith(creationOptions, authenticator, ORIGIN)).doesNotThrowAnyException();
}
@Test
@DisplayName("the same credential id cannot be registered twice")
void duplicateCredentialIdIsRejected() {
VirtualAuthenticator authenticator = new VirtualAuthenticator();
register(authenticator, ORIGIN);
assertThatThrownBy(() -> register(authenticator, ORIGIN)).hasMessageContaining("already exists");
}
// ------------------------------------------------------------------ ceremony helpers
private CredentialRecord register(VirtualAuthenticator authenticator, String origin) {
PublicKeyCredentialCreationOptions creationOptions = this.operations
.createPublicKeyCredentialCreationOptions(new ImmutablePublicKeyCredentialCreationOptionsRequest(
this.authentication));
return registerWith(creationOptions, authenticator, origin);
}
private CredentialRecord registerWith(PublicKeyCredentialCreationOptions creationOptions,
VirtualAuthenticator authenticator, String origin) {
VirtualAuthenticator.Registration created = authenticator.makeCredential(RP_ID, origin,
creationOptions.getChallenge().toBase64UrlString());
AuthenticatorAttestationResponse response = AuthenticatorAttestationResponse.builder()
.attestationObject(Bytes.fromBase64(created.attestationObject()))
.clientDataJSON(Bytes.fromBase64(created.clientDataJson()))
.transports(AuthenticatorTransport.INTERNAL, AuthenticatorTransport.HYBRID)
.build();
PublicKeyCredential<AuthenticatorAttestationResponse> credential = PublicKeyCredential
.<AuthenticatorAttestationResponse>builder()
.id(created.credentialId())
.rawId(Bytes.fromBase64(created.credentialId()))
.type(PublicKeyCredentialType.PUBLIC_KEY)
.response(response)
.build();
return this.operations.registerCredential(new ImmutableRelyingPartyRegistrationRequest(creationOptions,
new RelyingPartyPublicKey(credential, "test-credential")));
}
private org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity authenticate(
VirtualAuthenticator authenticator, String origin) {
PublicKeyCredentialRequestOptions requestOptions = this.operations
.createCredentialRequestOptions(new ImmutablePublicKeyCredentialRequestOptionsRequest(null));
VirtualAuthenticator.Assertion assertion = authenticator.getAssertion(RP_ID, origin,
requestOptions.getChallenge().toBase64UrlString(), null);
AuthenticatorAssertionResponse response = AuthenticatorAssertionResponse.builder()
.authenticatorData(Bytes.fromBase64(assertion.authenticatorData()))
.clientDataJSON(Bytes.fromBase64(assertion.clientDataJson()))
.signature(Bytes.fromBase64(assertion.signature()))
.build();
PublicKeyCredential<AuthenticatorAssertionResponse> credential = PublicKeyCredential
.<AuthenticatorAssertionResponse>builder()
.id(assertion.credentialId())
.rawId(Bytes.fromBase64(assertion.credentialId()))
.type(PublicKeyCredentialType.PUBLIC_KEY)
.response(response)
.build();
return this.operations.authenticate(new RelyingPartyAuthenticationRequest(requestOptions, credential));
}
}

View File

@@ -0,0 +1,406 @@
/*
* Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
* repository root.
*/
import java.net.CookieHandler;
import java.net.CookieManager;
import java.net.CookiePolicy;
import java.net.HttpCookie;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.ankurm.passkeys.virtual.VirtualAuthenticator;
/**
* Drives both WebAuthn ceremonies against a running instance, with no browser.
*
* <pre>
* java --class-path target/classes:target/deps/* tools/PasskeyCeremony.java [scenario]
* </pre>
*
* Scenarios:
* <ul>
* <li>{@code register-and-login} (default) - password login, register a passkey, log out, log
* back in with the passkey</li>
* <li>{@code clone-counter} - register, authenticate with an increasing counter, then replay a
* stale counter and see whether the relying party notices</li>
* <li>{@code no-uv} - register and authenticate with the UV flag clear</li>
* <li>{@code wrong-origin} - register with client data from an origin the relying party did
* not allow</li>
* <li>{@code wrong-origin-login} - assert from a disallowed origin, which fails differently
* from registering from one</li>
* <li>{@code duplicate} - register the same credential id twice</li>
* <li>{@code ott} - request a one-time token and redeem it, the fallback path</li>
* <li>{@code filters} - print the live security filter chain</li>
* <li>{@code bootstrap} - what happens when nobody is logged in yet</li>
* <li>{@code stepup} - an endpoint that only a passkey session can reach</li>
* </ul>
*
* Everything it prints is the real request and the real response. Nothing is transcribed.
*/
public final class PasskeyCeremony {
private static final String BASE = System.getProperty("demo.base", "http://localhost:8080");
private static final String ORIGIN = System.getProperty("demo.origin", BASE);
private static final String RP_ID = System.getProperty("demo.rpId", "localhost");
private static final Pattern CSRF = Pattern
.compile("name=\"_csrf\"[^>]*value=\"([^\"]+)\"|value=\"([^\"]+)\"[^>]*name=\"_csrf\"");
private final HttpClient http;
private final CookieManager cookies = new CookieManager(null, CookiePolicy.ACCEPT_ALL);
private PasskeyCeremony() {
CookieHandler.setDefault(this.cookies);
this.http = HttpClient.newBuilder()
.cookieHandler(this.cookies)
.followRedirects(HttpClient.Redirect.NEVER)
.connectTimeout(Duration.ofSeconds(5))
.build();
}
public static void main(String[] args) throws Exception {
String scenario = (args.length > 0) ? args[0] : "register-and-login";
PasskeyCeremony ceremony = new PasskeyCeremony();
switch (scenario) {
case "register-and-login" -> ceremony.registerAndLogin();
case "clone-counter" -> ceremony.cloneCounter();
case "no-uv" -> ceremony.noUserVerification();
case "wrong-origin" -> ceremony.wrongOrigin();
case "wrong-origin-login" -> ceremony.wrongOriginLogin();
case "duplicate" -> ceremony.duplicateRegistration();
case "ott" -> ceremony.oneTimeToken();
case "filters" -> ceremony.filters();
case "bootstrap" -> ceremony.bootstrap();
case "stepup" -> ceremony.stepUp();
default -> throw new IllegalArgumentException("unknown scenario: " + scenario);
}
}
// ---------------------------------------------------------------- scenarios
private void registerAndLogin() throws Exception {
banner("Registration ceremony, then authentication ceremony, no browser involved");
passwordLogin("user", "password");
VirtualAuthenticator authenticator = new VirtualAuthenticator();
register(authenticator, "yubikey-on-my-desk");
print("GET /diag/credentials", get("/diag/credentials"));
logout();
banner("Session dropped. Authenticating with the passkey alone");
authenticate(authenticator, null);
print("GET /me", get("/me"));
}
private void cloneCounter() throws Exception {
banner("Signature counter: does the relying party detect a cloned authenticator?");
passwordLogin("user", "password");
VirtualAuthenticator authenticator = new VirtualAuthenticator();
register(authenticator, "counter-demo");
logout();
for (int i = 1; i <= 3; i++) {
authenticator.signCount(i);
System.out.printf("%n--- assertion %d, authenticator signCount = %d%n", i, authenticator.signCount());
authenticate(authenticator, null);
System.out.println("stored signatureCount now: " + storedSignatureCount("user", "password"));
logout();
}
banner("Replaying a stale counter. A cloned key would look exactly like this");
authenticator.signCount(1);
System.out.println("--- assertion 4, authenticator signCount = 1 (lower than the stored 3)");
HttpResponse<String> replay = authenticate(authenticator, "replay");
System.out.printf("%nreplayed a counter of 1 after the relying party had stored 3: HTTP %d%n",
replay.statusCode());
}
private void noUserVerification() throws Exception {
banner("A credential created and asserted with the UV flag clear");
passwordLogin("user", "password");
VirtualAuthenticator authenticator = new VirtualAuthenticator()
.flags(VirtualAuthenticator.FLAG_UP | VirtualAuthenticator.FLAG_BE | VirtualAuthenticator.FLAG_BS);
HttpResponse<String> registration = register(authenticator, "no-uv");
if (registration.statusCode() != 200) {
System.out.println("registration refused, which is what userVerification REQUIRED does");
return;
}
print("GET /diag/credentials (note uvInitialized)", get("/diag/credentials"));
logout();
authenticator.signCount(1);
HttpResponse<String> assertion = authenticate(authenticator, "no-uv");
System.out.printf("%nauthentication with UV clear: HTTP %d%n", assertion.statusCode());
}
private void wrongOrigin() throws Exception {
banner("Client data from an origin the relying party did not allow");
passwordLogin("user", "password");
VirtualAuthenticator authenticator = new VirtualAuthenticator();
String optionsJson = post("/webauthn/register/options", "").body();
String challenge = jsonString(optionsJson, "challenge");
VirtualAuthenticator.Registration credential = authenticator.makeCredential(RP_ID,
"http://evil.localhost:8080", challenge);
String body = registrationBody(credential, "phished");
HttpResponse<String> response = post("/webauthn/register", body);
System.out.println("origin sent by the client: http://evil.localhost:8080");
System.out.println("origin allowed by the relying party: " + ORIGIN);
print("POST /webauthn/register", response);
}
private void wrongOriginLogin() throws Exception {
banner("An assertion from a disallowed origin - the same mistake, one ceremony later");
passwordLogin("user", "password");
VirtualAuthenticator authenticator = new VirtualAuthenticator();
register(authenticator, "phishable");
logout();
HttpResponse<String> options = post("/webauthn/authenticate/options", "");
String challenge = jsonString(options.body(), "challenge");
VirtualAuthenticator.Assertion assertion = authenticator.getAssertion(RP_ID, "http://evil.localhost:8080",
challenge, null);
String body = """
{"id":"%s","rawId":"%s","response":{"authenticatorData":"%s","clientDataJSON":"%s","signature":"%s"},\
"clientExtensionResults":{},"type":"public-key","authenticatorAttachment":"platform"}"""
.formatted(assertion.credentialId(), assertion.credentialId(), assertion.authenticatorData(),
assertion.clientDataJson(), assertion.signature());
print("POST /login/webauthn (origin http://evil.localhost:8080)", post("/login/webauthn", body));
}
private void duplicateRegistration() throws Exception {
banner("Registering the same credential id twice");
passwordLogin("user", "password");
VirtualAuthenticator authenticator = new VirtualAuthenticator();
register(authenticator, "first");
System.out.println("\nsame authenticator, same credential id, second registration:");
HttpResponse<String> options = post("/webauthn/register/options", "");
System.out.println("excludeCredentials now: " + jsonArray(options.body(), "excludeCredentials"));
String challenge = jsonString(options.body(), "challenge");
VirtualAuthenticator.Registration credential = authenticator.makeCredential(RP_ID, ORIGIN, challenge);
print("POST /webauthn/register", post("/webauthn/register", registrationBody(credential, "second")));
}
private void oneTimeToken() throws Exception {
banner("One-time token: the way in when there is no passkey yet, and the way back");
String csrf = csrfToken("/login");
HttpResponse<String> generated = form("/ott/generate", "username=user", csrf);
System.out.printf("POST /ott/generate -> HTTP %d, Location: %s%n", generated.statusCode(),
generated.headers().firstValue("location").orElse("-"));
String token = java.nio.file.Files
.readString(java.nio.file.Path.of(System.getProperty("java.io.tmpdir"), "passkeys-demo-ott.txt"))
.trim();
System.out.println("token delivered out of band (the handler wrote it to a file): " + token);
String submitCsrf = csrfToken("/login/ott?token=" + URLEncoder.encode(token, StandardCharsets.UTF_8));
HttpResponse<String> redeemed = form("/login/ott",
"token=" + URLEncoder.encode(token, StandardCharsets.UTF_8), submitCsrf);
System.out.printf("POST /login/ott -> HTTP %d, Location: %s%n", redeemed.statusCode(),
redeemed.headers().firstValue("location").orElse("-"));
print("GET /me", get("/me"));
banner("The same token, a second time");
String replayCsrf = csrfToken("/login");
HttpResponse<String> replay = form("/login/ott", "token=" + URLEncoder.encode(token, StandardCharsets.UTF_8),
replayCsrf);
System.out.printf("POST /login/ott -> HTTP %d, Location: %s%n", replay.statusCode(),
replay.headers().firstValue("location").orElse("-"));
}
private void bootstrap() throws Exception {
banner("Asking for registration options with nobody logged in");
print("POST /webauthn/register/options (anonymous)", post("/webauthn/register/options", ""));
banner("A one-time token for a username that does not exist");
String csrf = csrfToken("/login");
HttpResponse<String> generated = form("/ott/generate", "username=nosuchuser", csrf);
System.out.printf("POST /ott/generate -> HTTP %d, Location: %s%n", generated.statusCode(),
generated.headers().firstValue("location").orElse("-"));
String token = java.nio.file.Files
.readString(java.nio.file.Path.of(System.getProperty("java.io.tmpdir"), "passkeys-demo-ott.txt"))
.trim();
System.out.println("a token was still generated and delivered: " + token);
System.out.println("the response is byte-for-byte what a real username produces - no enumeration oracle");
String submitCsrf = csrfToken("/login");
HttpResponse<String> redeemed = form("/login/ott",
"token=" + URLEncoder.encode(token, StandardCharsets.UTF_8), submitCsrf);
System.out.printf("POST /login/ott -> HTTP %d, Location: %s (the failure lands here instead)%n",
redeemed.statusCode(), redeemed.headers().firstValue("location").orElse("-"));
}
private void stepUp() throws Exception {
banner("An endpoint guarded by hasAuthority(\"FACTOR_WEBAUTHN\")");
passwordLogin("user", "password");
VirtualAuthenticator authenticator = new VirtualAuthenticator();
register(authenticator, "step-up-demo");
HttpResponse<String> passwordAttempt = get("/passkey-only");
System.out.printf("%npassword session -> GET /passkey-only: HTTP %d, Location: %s%n",
passwordAttempt.statusCode(), passwordAttempt.headers().firstValue("location").orElse("-"));
logout();
oneTimeToken();
HttpResponse<String> ottAttempt = get("/passkey-only");
System.out.printf("%none-time-token session -> GET /passkey-only: HTTP %d, Location: %s%n",
ottAttempt.statusCode(), ottAttempt.headers().firstValue("location").orElse("-"));
logout();
authenticator.signCount(1);
authenticate(authenticator, "stepup");
print("passkey session -> GET /passkey-only", get("/passkey-only"));
}
private void filters() throws Exception {
passwordLogin("user", "password");
String body = get("/diag/filters").body();
Matcher matcher = Pattern.compile("\"( ?\\d+ [A-Za-z0-9]+)\"").matcher(body);
while (matcher.find()) {
System.out.println(matcher.group(1));
}
}
// ---------------------------------------------------------------- ceremony steps
private HttpResponse<String> register(VirtualAuthenticator authenticator, String label) throws Exception {
HttpResponse<String> options = post("/webauthn/register/options", "");
print("POST /webauthn/register/options", options);
String challenge = jsonString(options.body(), "challenge");
VirtualAuthenticator.Registration credential = authenticator.makeCredential(RP_ID, ORIGIN, challenge);
System.out.println("authenticator produced credentialId " + credential.credentialId() + " and a "
+ VirtualAuthenticator.decodeBase64Url(credential.attestationObject()).length
+ "-byte CBOR attestation object");
HttpResponse<String> response = post("/webauthn/register", registrationBody(credential, label));
print("POST /webauthn/register", response);
return response;
}
private HttpResponse<String> authenticate(VirtualAuthenticator authenticator, String note) throws Exception {
HttpResponse<String> options = post("/webauthn/authenticate/options", "");
if (note == null) {
print("POST /webauthn/authenticate/options", options);
}
String challenge = jsonString(options.body(), "challenge");
VirtualAuthenticator.Assertion assertion = authenticator.getAssertion(RP_ID, ORIGIN, challenge, null);
String body = """
{"id":"%s","rawId":"%s","response":{"authenticatorData":"%s","clientDataJSON":"%s","signature":"%s"},\
"clientExtensionResults":{},"type":"public-key","authenticatorAttachment":"platform"}"""
.formatted(assertion.credentialId(), assertion.credentialId(), assertion.authenticatorData(),
assertion.clientDataJson(), assertion.signature());
HttpResponse<String> response = post("/login/webauthn", body);
print("POST /login/webauthn", response);
return response;
}
private String registrationBody(VirtualAuthenticator.Registration credential, String label) {
return """
{"publicKey":{"credential":{"id":"%s","rawId":"%s","response":{"attestationObject":"%s",\
"clientDataJSON":"%s","transports":["internal","hybrid"]},"type":"public-key",\
"clientExtensionResults":{},"authenticatorAttachment":"platform"},"label":"%s"}}"""
.formatted(credential.credentialId(), credential.credentialId(), credential.attestationObject(),
credential.clientDataJson(), label);
}
private void passwordLogin(String username, String password) throws Exception {
String csrf = csrfToken("/login");
HttpResponse<String> response = form("/login", "username=" + username + "&password=" + password, csrf);
System.out.printf("POST /login (password) -> HTTP %d, Location: %s%n", response.statusCode(),
response.headers().firstValue("location").orElse("-"));
}
private void logout() throws Exception {
String csrf = csrfToken("/login");
form("/logout", "", csrf);
this.cookies.getCookieStore().removeAll();
System.out.println("logged out, cookie jar emptied");
}
private long storedSignatureCount(String username, String password) throws Exception {
CookieManager saved = new CookieManager(null, CookiePolicy.ACCEPT_ALL);
List<HttpCookie> current = this.cookies.getCookieStore().getCookies();
current.forEach((c) -> saved.getCookieStore().add(null, c));
passwordLogin(username, password);
String body = get("/diag/credentials").body();
Matcher matcher = Pattern.compile("\"signatureCount\"\\s*:\\s*(\\d+)").matcher(body);
return matcher.find() ? Long.parseLong(matcher.group(1)) : -1;
}
// ---------------------------------------------------------------- plumbing
private String csrfToken(String path) throws Exception {
HttpResponse<String> page = get(path);
Matcher matcher = CSRF.matcher(page.body());
if (matcher.find()) {
return (matcher.group(1) != null) ? matcher.group(1) : matcher.group(2);
}
return this.cookies.getCookieStore()
.getCookies()
.stream()
.filter((c) -> "XSRF-TOKEN".equals(c.getName()))
.map(HttpCookie::getValue)
.findFirst()
.orElseThrow(() -> new IllegalStateException("no CSRF token found on " + path));
}
private HttpResponse<String> get(String path) throws Exception {
return this.http.send(HttpRequest.newBuilder(URI.create(BASE + path)).GET().build(),
HttpResponse.BodyHandlers.ofString());
}
/** JSON POST with the CSRF token in a header, which is how the WebAuthn endpoints work. */
private HttpResponse<String> post(String path, String json) throws Exception {
String csrf = csrfToken("/login");
HttpRequest request = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Content-Type", "application/json")
.header("X-CSRF-TOKEN", csrf)
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
return this.http.send(request, HttpResponse.BodyHandlers.ofString());
}
private HttpResponse<String> form(String path, String body, String csrf) throws Exception {
String payload = body.isEmpty() ? "_csrf=" + URLEncoder.encode(csrf, StandardCharsets.UTF_8)
: body + "&_csrf=" + URLEncoder.encode(csrf, StandardCharsets.UTF_8);
HttpRequest request = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
return this.http.send(request, HttpResponse.BodyHandlers.ofString());
}
private static String jsonArray(String json, String field) {
Matcher matcher = Pattern.compile("\"" + field + "\"\\s*:\\s*(\\[[^\\]]*\\])").matcher(json);
return matcher.find() ? matcher.group(1) : "?";
}
private static String jsonString(String json, String field) {
Matcher matcher = Pattern.compile("\"" + field + "\"\\s*:\\s*\"([^\"]+)\"").matcher(json);
if (!matcher.find()) {
throw new IllegalStateException("no \"" + field + "\" in: " + json);
}
return matcher.group(1);
}
private static void print(String what, HttpResponse<String> response) {
String body = response.body();
if (body.length() > 1200) {
body = body.substring(0, 1200) + "\n... (truncated)";
}
System.out.printf("%n$ %s%nHTTP %d%n%s%n", what, response.statusCode(), body.isBlank() ? "(empty body)" : body);
}
private static void banner(String text) {
System.out.printf("%n=== %s ===%n", text);
}
}