Fourth Maven project in the repository. Registration and authentication run end to end with no browser and no hardware key: VirtualAuthenticator emits real CBOR attestation objects and real ES256 assertion signatures, and tools/PasskeyCeremony.java drives the live HTTP endpoints with them. Profiles cover userVerification REQUIRED, DIRECT attestation, a disallowed origin and JDBC persistence. Eleven doc chapters and twelve captured transcripts under docs/passkeys and docs/output/pk-*.txt, all regenerated by passkeys/scripts/run-all.sh.
4.7 KiB
← 08 — The one-time-token fallback · index · next: 10 — The signature counter
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
@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). They are still
on the classpath transitively, so this works:
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 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:
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.
Things to decide before you ship
- Deleting a credential.
DELETE /webauthn/register/{id}exists and is guarded byCredentialRecordOwnerAuthorizationManager, 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
HttpSessionbetween 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. - 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 · index · next: 10 — The signature counter