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

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. + * + *

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); + } + +} diff --git a/passkeys/src/main/java/com/ankurm/passkeys/config/AppUsers.java b/passkeys/src/main/java/com/ankurm/passkeys/config/AppUsers.java new file mode 100644 index 0000000..f404e67 --- /dev/null +++ b/passkeys/src/main/java/com/ankurm/passkeys/config/AppUsers.java @@ -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. + * + *

{@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); + } + +} diff --git a/passkeys/src/main/java/com/ankurm/passkeys/config/PersistenceConfig.java b/passkeys/src/main/java/com/ankurm/passkeys/config/PersistenceConfig.java new file mode 100644 index 0000000..0248adf --- /dev/null +++ b/passkeys/src/main/java/com/ankurm/passkeys/config/PersistenceConfig.java @@ -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. + * + *

{@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. + * + *

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); + } + + } + +} diff --git a/passkeys/src/main/java/com/ankurm/passkeys/config/RelyingPartyConfig.java b/passkeys/src/main/java/com/ankurm/passkeys/config/RelyingPartyConfig.java new file mode 100644 index 0000000..411c892 --- /dev/null +++ b/passkeys/src/main/java/com/ankurm/passkeys/config/RelyingPartyConfig.java @@ -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. + * + *

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. + * + *

Profiles: + * + *

+ * + * @see docs/passkeys/05-defaults.md + */ +@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; + } + +} diff --git a/passkeys/src/main/java/com/ankurm/passkeys/config/SecurityConfig.java b/passkeys/src/main/java/com/ankurm/passkeys/config/SecurityConfig.java new file mode 100644 index 0000000..7995cd8 --- /dev/null +++ b/passkeys/src/main/java/com/ankurm/passkeys/config/SecurityConfig.java @@ -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. + * + *

Three things about this file are worth more than they look: + * + *

+ * + * @see docs/passkeys/02-minimum-configuration.md + */ +@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(); + } + +} diff --git a/passkeys/src/main/java/com/ankurm/passkeys/diag/CredentialDiagnostics.java b/passkeys/src/main/java/com/ankurm/passkeys/diag/CredentialDiagnostics.java new file mode 100644 index 0000000..075960f --- /dev/null +++ b/passkeys/src/main/java/com/ankurm/passkeys/diag/CredentialDiagnostics.java @@ -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. + * + *

{@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. + * + *

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 credentials(Authentication authentication) { + Map 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> records = new ArrayList<>(); + for (CredentialRecord record : this.userCredentials.findByUserId(userEntity.getId())) { + Map 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 filters() { + Map result = new LinkedHashMap<>(); + List> chains = new ArrayList<>(); + for (SecurityFilterChain chain : this.filterChainProxy.getFilterChains()) { + List 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; + } + +} diff --git a/passkeys/src/main/java/com/ankurm/passkeys/ott/ConsoleOneTimeTokenHandler.java b/passkeys/src/main/java/com/ankurm/passkeys/ott/ConsoleOneTimeTokenHandler.java new file mode 100644 index 0000000..5e08a43 --- /dev/null +++ b/passkeys/src/main/java/com/ankurm/passkeys/ott/ConsoleOneTimeTokenHandler.java @@ -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. + * + *

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. + * + *

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. + * + *

{@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); + } + +} diff --git a/passkeys/src/main/java/com/ankurm/passkeys/virtual/Cbor.java b/passkeys/src/main/java/com/ankurm/passkeys/virtual/Cbor.java new file mode 100644 index 0000000..2ae8993 --- /dev/null +++ b/passkeys/src/main/java/com/ankurm/passkeys/virtual/Cbor.java @@ -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. + * + *

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. + * + *

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 RFC 8949 - Concise Binary Object + * Representation + * @see VirtualAuthenticator + * @see docs/passkeys/04-virtual-authenticator.md + */ +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(); + } + +} diff --git a/passkeys/src/main/java/com/ankurm/passkeys/virtual/VirtualAuthenticator.java b/passkeys/src/main/java/com/ankurm/passkeys/virtual/VirtualAuthenticator.java new file mode 100644 index 0000000..d797c8c --- /dev/null +++ b/passkeys/src/main/java/com/ankurm/passkeys/virtual/VirtualAuthenticator.java @@ -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. + * + *

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. + * + *

What it deliberately does not 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. + * + *

The signature counter is a field you control, which is the point of + * {@code scripts/clone-counter.sh}. + * + * @see WebAuthn Level 3, + * section 6.1 - Authenticator Data + * @see docs/passkeys/04-virtual-authenticator.md + */ +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()}. + * + *

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) { + } + +} diff --git a/passkeys/src/main/java/com/ankurm/passkeys/web/ApiControllers.java b/passkeys/src/main/java/com/ankurm/passkeys/web/ApiControllers.java new file mode 100644 index 0000000..7e0ebff --- /dev/null +++ b/passkeys/src/main/java/com/ankurm/passkeys/web/ApiControllers.java @@ -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 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 passkeyOnly() { + return Map.of("ok", "this endpoint required FACTOR_WEBAUTHN"); + } + + @GetMapping("/me") + public Map me(Authentication authentication) { + Map 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; + } + +} diff --git a/passkeys/src/main/resources/application-attestationdirect.yaml b/passkeys/src/main/resources/application-attestationdirect.yaml new file mode 100644 index 0000000..04985df --- /dev/null +++ b/passkeys/src/main/resources/application-attestationdirect.yaml @@ -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 diff --git a/passkeys/src/main/resources/application-badorigin.yaml b/passkeys/src/main/resources/application-badorigin.yaml new file mode 100644 index 0000000..d4fd1ae --- /dev/null +++ b/passkeys/src/main/resources/application-badorigin.yaml @@ -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 diff --git a/passkeys/src/main/resources/application-jdbc.yaml b/passkeys/src/main/resources/application-jdbc.yaml new file mode 100644 index 0000000..3a0abd2 --- /dev/null +++ b/passkeys/src/main/resources/application-jdbc.yaml @@ -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 diff --git a/passkeys/src/main/resources/application-trace.yaml b/passkeys/src/main/resources/application-trace.yaml new file mode 100644 index 0000000..07f823c --- /dev/null +++ b/passkeys/src/main/resources/application-trace.yaml @@ -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 diff --git a/passkeys/src/main/resources/application-uvrequired.yaml b/passkeys/src/main/resources/application-uvrequired.yaml new file mode 100644 index 0000000..9575b44 --- /dev/null +++ b/passkeys/src/main/resources/application-uvrequired.yaml @@ -0,0 +1,3 @@ +# User verification promoted from PREFERRED (the default) to REQUIRED, on both ceremonies. +demo: + user-verification-required: true diff --git a/passkeys/src/main/resources/application.yaml b/passkeys/src/main/resources/application.yaml new file mode 100644 index 0000000..347b6bf --- /dev/null +++ b/passkeys/src/main/resources/application.yaml @@ -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 diff --git a/passkeys/src/test/java/com/ankurm/passkeys/PasskeyContractTests.java b/passkeys/src/test/java/com/ankurm/passkeys/PasskeyContractTests.java new file mode 100644 index 0000000..454f219 --- /dev/null +++ b/passkeys/src/test/java/com/ankurm/passkeys/PasskeyContractTests.java @@ -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. + * + *

These pin behaviour rather than the happy path. Several of them assert that something + * is not 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. + * + *

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 docs/passkeys/05-defaults.md + */ +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 credential = PublicKeyCredential + .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 credential = PublicKeyCredential + .builder() + .id(assertion.credentialId()) + .rawId(Bytes.fromBase64(assertion.credentialId())) + .type(PublicKeyCredentialType.PUBLIC_KEY) + .response(response) + .build(); + + return this.operations.authenticate(new RelyingPartyAuthenticationRequest(requestOptions, credential)); + } + +} diff --git a/passkeys/tools/PasskeyCeremony.java b/passkeys/tools/PasskeyCeremony.java new file mode 100644 index 0000000..44cbafd --- /dev/null +++ b/passkeys/tools/PasskeyCeremony.java @@ -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. + * + *

+ * java --class-path target/classes:target/deps/* tools/PasskeyCeremony.java [scenario]
+ * 
+ * + * Scenarios: + * + * + * 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 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 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 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 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 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 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 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 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 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 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 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 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 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 register(VirtualAuthenticator authenticator, String label) throws Exception { + HttpResponse 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 response = post("/webauthn/register", registrationBody(credential, label)); + print("POST /webauthn/register", response); + return response; + } + + private HttpResponse authenticate(VirtualAuthenticator authenticator, String note) throws Exception { + HttpResponse 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 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 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 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 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 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 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 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 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); + } + +}