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