From 174e3a93157bbdb6dfa590d5d3cb105d0bdf187c Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Thu, 20 Aug 2026 14:01:17 -0600 Subject: [PATCH] feat: audit and notify on WebAuthn credential registration Enrolling a passkey grants a durable new way into an account, and it outlives a password change, because session invalidation ends sessions rather than credentials. An attacker who reaches an authenticated session can therefore leave themselves a way back in that survives the victim's response. Until now that enrollment produced no audit record and no notification: Spring Security owns POST /webauthn/register, and nothing in this framework observed it. The framework does own the JPA UserCredentialRepository every enrollment writes through, so the event is published from there and catches any path that registers a credential. The trap, and why the discriminator matters: Spring Security also calls UserCredentialRepository.save() inside authenticate(), to persist the updated signature count. Publishing on every save would email the user on each passkey login. save() already resolves the row with findById(...).orElseGet(new), so an absent row identifies a genuine registration with no extra query. A test pins this by asserting no event on the update path; sabotaging the guard fails it. - WebAuthnCredentialRegisteredEvent carrying the user, credential id, and label - WebAuthnCredentialRegistrationListener publishing a PasskeyRegistration audit event, then emailing the owner - user.webauthn.notifyOnRegistration (default true) gates the email only; the audit event is unconditional, and a mail failure cannot lose it - New mail template and message keys naming the specific risk: a passkey outlives a password change, so both steps are needed to recover This is detective, not preventive. Preventing enrollment from a session-only actor is step-up (#335 / #365), which is off by default and does not yet gate enrollment. This applies whether or not step-up is on. ./gradlew check: green. ./gradlew javadoc: clean. --- CONFIG.md | 1 + .../WebAuthnCredentialRegisteredEvent.java | 52 +++++++++ ...ebAuthnCredentialRegistrationListener.java | 57 ++++++++++ .../security/WebAuthnConfigProperties.java | 7 ++ .../security/WebAuthnRepositoryConfig.java | 23 +++- .../spring/user/service/UserEmailService.java | 23 ++++ .../config/dsspringuserconfig.properties | 3 + .../messages/dsspringusermessages.properties | 3 + .../mail/webauthn-credential-registered.html | 20 ++++ ...thnCredentialRegistrationListenerTest.java | 92 +++++++++++++++ ...bAuthnCredentialRegistrationEventTest.java | 105 ++++++++++++++++++ 11 files changed, 382 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/digitalsanctuary/spring/user/event/WebAuthnCredentialRegisteredEvent.java create mode 100644 src/main/java/com/digitalsanctuary/spring/user/listener/WebAuthnCredentialRegistrationListener.java create mode 100644 src/main/resources/templates/mail/webauthn-credential-registered.html create mode 100644 src/test/java/com/digitalsanctuary/spring/user/listener/WebAuthnCredentialRegistrationListenerTest.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/security/WebAuthnCredentialRegistrationEventTest.java diff --git a/CONFIG.md b/CONFIG.md index 2fcb84df..b6bcecff 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -178,6 +178,7 @@ Provides passwordless login using biometrics, security keys, or device authentic - **Relying Party ID (`user.webauthn.rpId`)**: For development, use `localhost`. For production, use your domain (e.g., `example.com`). Defaults to `localhost`. - **Relying Party Name (`user.webauthn.rpName`)**: The display name. - **Allowed Origins (`user.webauthn.allowedOrigins`)**: Comma-separated list of allowed origins. Defaults to `https://localhost:8443`. +- **Registration notification (`user.webauthn.notifyOnRegistration`)**: Email the account owner when a passkey is registered on their account. Defaults to `true`. Enrolling a passkey grants a durable new way into the account that survives a password change, since session invalidation ends sessions rather than credentials, so an enrollment the owner did not perform is worth surfacing. A `PasskeyRegistration` audit event is recorded either way. Set to `false` only if your application sends its own equivalent notification. **Development Example:** ```properties diff --git a/src/main/java/com/digitalsanctuary/spring/user/event/WebAuthnCredentialRegisteredEvent.java b/src/main/java/com/digitalsanctuary/spring/user/event/WebAuthnCredentialRegisteredEvent.java new file mode 100644 index 00000000..1b75a511 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/event/WebAuthnCredentialRegisteredEvent.java @@ -0,0 +1,52 @@ +package com.digitalsanctuary.spring.user.event; + +import com.digitalsanctuary.spring.user.persistence.model.User; +import lombok.Getter; +import lombok.ToString; +import org.springframework.context.ApplicationEvent; + +/** + * Published when a user registers a new WebAuthn credential (passkey). + * + *

+ * Enrolling a passkey grants a durable new way into the account, and it survives a password change, since session + * invalidation ends sessions rather than credentials. Spring Security owns the endpoint that performs it + * ({@code POST /webauthn/register}), so the framework observes enrollment where it writes through: the JPA + * {@code UserCredentialRepository}. That catches every enrollment regardless of which endpoint triggered it. + *

+ * + *

+ * The event fires only for a genuinely new credential. Spring Security also saves through the same repository on + * every successful assertion, to persist the updated signature count, and that is not a registration. + *

+ */ +@Getter +@ToString(callSuper = false) +public class WebAuthnCredentialRegisteredEvent extends ApplicationEvent { + + private static final long serialVersionUID = 1L; + + /** The user who registered the credential. */ + private final transient User user; + + /** The base64url credential id, useful for correlating with the audit log. */ + private final String credentialId; + + /** The user-supplied label for the credential, or {@code "Passkey"} when none was given. */ + private final String label; + + /** + * Creates the event. + * + * @param source the component publishing the event + * @param user the user who registered the credential + * @param credentialId the base64url credential id + * @param label the credential label + */ + public WebAuthnCredentialRegisteredEvent(Object source, User user, String credentialId, String label) { + super(source); + this.user = user; + this.credentialId = credentialId; + this.label = label; + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/listener/WebAuthnCredentialRegistrationListener.java b/src/main/java/com/digitalsanctuary/spring/user/listener/WebAuthnCredentialRegistrationListener.java new file mode 100644 index 00000000..c7c78625 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/listener/WebAuthnCredentialRegistrationListener.java @@ -0,0 +1,57 @@ +package com.digitalsanctuary.spring.user.listener; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ApplicationListener; +import org.springframework.stereotype.Component; +import com.digitalsanctuary.spring.user.audit.AuditEvent; +import com.digitalsanctuary.spring.user.event.WebAuthnCredentialRegisteredEvent; +import com.digitalsanctuary.spring.user.security.WebAuthnConfigProperties; +import com.digitalsanctuary.spring.user.service.UserEmailService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Records and announces passkey enrollment. + * + *

+ * A newly enrolled credential is a durable new way into the account, and it outlives a password change, since + * session invalidation ends sessions rather than credentials. An attacker who reaches an authenticated session can + * therefore leave themselves a way back in. Preventing that is the job of step-up + * ({@code user.security.stepUp.enabled}); this listener is the detective half, so the enrollment is at least + * recorded and visible to the account owner whether or not step-up is switched on. + *

+ */ +@Slf4j +@Component +@RequiredArgsConstructor +public class WebAuthnCredentialRegistrationListener implements ApplicationListener { + + private final UserEmailService userEmailService; + private final ApplicationEventPublisher eventPublisher; + private final WebAuthnConfigProperties webAuthnConfigProperties; + + /** + * Audits the enrollment and, unless disabled, emails the account owner. + * + * @param event the registration event + */ + @Override + public void onApplicationEvent(WebAuthnCredentialRegisteredEvent event) { + // Audit first and unconditionally: the notification is a courtesy the operator can switch off, and a mail + // outage must not cost us the security-relevant record of the enrollment. + eventPublisher.publishEvent(AuditEvent.builder().source(this).user(event.getUser()) + .action("PasskeyRegistration").actionStatus("Success") + .message("Passkey registered: " + event.getLabel()).build()); + + if (!webAuthnConfigProperties.isNotifyOnRegistration()) { + return; + } + + try { + userEmailService.sendPasskeyRegisteredNotification(event.getUser(), event.getLabel()); + } catch (RuntimeException e) { + // Never let a mail failure propagate into the registration flow, which has already committed. + log.error("Failed to send passkey registration notification to user {}", event.getUser().getId(), e); + } + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnConfigProperties.java b/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnConfigProperties.java index e30a9780..4488017f 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnConfigProperties.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnConfigProperties.java @@ -30,4 +30,11 @@ public class WebAuthnConfigProperties { * Whether Passkey support is enabled. */ private boolean enabled = false; + + /** + * Whether to email the account owner when a passkey is registered on their account. Enrolling a passkey grants a + * durable new way into the account that survives a password change, so the owner is told by default. Set to + * {@code false} only if your application sends its own equivalent notification. + */ + private boolean notifyOnRegistration = true; } diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnRepositoryConfig.java b/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnRepositoryConfig.java index 00c04c9a..5a7eb9bb 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnRepositoryConfig.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnRepositoryConfig.java @@ -2,9 +2,11 @@ import java.util.Base64; import java.util.List; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.web.webauthn.api.AuthenticatorTransport; @@ -15,6 +17,7 @@ import org.springframework.security.web.webauthn.api.PublicKeyCredentialType; import org.springframework.security.web.webauthn.management.UserCredentialRepository; import org.springframework.transaction.annotation.Transactional; +import com.digitalsanctuary.spring.user.event.WebAuthnCredentialRegisteredEvent; import com.digitalsanctuary.spring.user.persistence.model.WebAuthnCredential; import com.digitalsanctuary.spring.user.persistence.model.WebAuthnUserEntity; import com.digitalsanctuary.spring.user.persistence.repository.WebAuthnCredentialRepository; @@ -47,13 +50,14 @@ public class WebAuthnRepositoryConfig { * * @param credentialRepository JPA repository for WebAuthn credentials * @param userEntityRepository JPA repository for WebAuthn user entities + * @param eventPublisher publishes {@link WebAuthnCredentialRegisteredEvent} when a new credential is enrolled * @return the UserCredentialRepository instance */ @Bean public UserCredentialRepository userCredentialRepository(WebAuthnCredentialRepository credentialRepository, - WebAuthnUserEntityRepository userEntityRepository) { + WebAuthnUserEntityRepository userEntityRepository, ApplicationEventPublisher eventPublisher) { log.info("Initializing JPA-backed WebAuthn UserCredentialRepository"); - return new JpaUserCredentialRepository(credentialRepository, userEntityRepository); + return new JpaUserCredentialRepository(credentialRepository, userEntityRepository, eventPublisher); } /** @@ -65,11 +69,13 @@ static class JpaUserCredentialRepository implements UserCredentialRepository { private final WebAuthnCredentialRepository credentialRepository; private final WebAuthnUserEntityRepository userEntityRepository; + private final ApplicationEventPublisher eventPublisher; JpaUserCredentialRepository(WebAuthnCredentialRepository credentialRepository, - WebAuthnUserEntityRepository userEntityRepository) { + WebAuthnUserEntityRepository userEntityRepository, ApplicationEventPublisher eventPublisher) { this.credentialRepository = credentialRepository; this.userEntityRepository = userEntityRepository; + this.eventPublisher = eventPublisher; } @Override @@ -77,7 +83,11 @@ static class JpaUserCredentialRepository implements UserCredentialRepository { public void save(CredentialRecord record) { String credIdStr = toBase64Url(record.getCredentialId().getBytes()); - WebAuthnCredential entity = credentialRepository.findById(credIdStr).orElseGet(WebAuthnCredential::new); + // Spring Security also saves through here on every successful assertion, to persist the updated + // signature count. Only an absent row is a registration; anything else is that update. + Optional existing = credentialRepository.findById(credIdStr); + boolean newCredential = existing.isEmpty(); + WebAuthnCredential entity = existing.orElseGet(WebAuthnCredential::new); entity.setCredentialId(credIdStr); // Look up the user entity @@ -104,6 +114,11 @@ public void save(CredentialRecord record) { entity.setLabel(record.getLabel() != null ? record.getLabel() : "Passkey"); credentialRepository.save(entity); + + if (newCredential) { + eventPublisher.publishEvent(new WebAuthnCredentialRegisteredEvent(this, userEntity.getUser(), + credIdStr, entity.getLabel())); + } } @Override diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/UserEmailService.java b/src/main/java/com/digitalsanctuary/spring/user/service/UserEmailService.java index 2593abd0..6d595062 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/UserEmailService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserEmailService.java @@ -175,6 +175,29 @@ public void sendRegistrationVerificationEmail(final Long userId, final String ap * @return the map * @throws IllegalArgumentException if appUrl is invalid (for admin-initiated resets) */ + /** + * Notifies the account owner that a passkey was registered on their account. + * + *

+ * Sent for every new credential, however it was enrolled. A passkey the owner did not add is a sign someone + * else reached their session, and it would otherwise be invisible until they went looking. + *

+ * + * @param user the account owner + * @param label the label of the newly registered passkey + */ + public void sendPasskeyRegisteredNotification(final User user, final String label) { + if (user == null || user.getEmail() == null) { + log.warn("UserEmailService.sendPasskeyRegisteredNotification: no recipient, skipping"); + return; + } + Map variables = new HashMap<>(); + variables.put("user", user); + variables.put("label", label != null ? label : "Passkey"); + mailService.sendTemplateMessage(user.getEmail(), "New passkey added to your account", variables, + "mail/webauthn-credential-registered.html"); + } + private Map createEmailVariables(final User user, final String appUrl, final String token, final String confirmationPath) { Map variables = new HashMap<>(); variables.put("token", token); diff --git a/src/main/resources/config/dsspringuserconfig.properties b/src/main/resources/config/dsspringuserconfig.properties index 563f4092..17cb77cc 100644 --- a/src/main/resources/config/dsspringuserconfig.properties +++ b/src/main/resources/config/dsspringuserconfig.properties @@ -194,6 +194,9 @@ user.webauthn.enabled=false user.webauthn.rpId=localhost user.webauthn.rpName=Spring User Framework user.webauthn.allowedOrigins=https://localhost:8443 +# Email the account owner when a passkey is registered. A passkey survives a password change, so an +# unrecognized one is worth telling the owner about. The audit event is recorded regardless of this setting. +user.webauthn.notifyOnRegistration=true # MFA (Multi-Factor Authentication) Configuration (disabled by default; opt-in feature) # When enabled, all authenticated endpoints require all configured factors to be satisfied. diff --git a/src/main/resources/messages/dsspringusermessages.properties b/src/main/resources/messages/dsspringusermessages.properties index 1c80803a..1a7b32b8 100644 --- a/src/main/resources/messages/dsspringusermessages.properties +++ b/src/main/resources/messages/dsspringusermessages.properties @@ -7,6 +7,9 @@ email.registration-confirmation.intro=Thank you for registering with the Spring email.registration-confirmation.link-instructions=You’ve successfully registered. To confirm your account, click the link below. email.registration-confirmation.link-expiration=This link will be valid for 24 hours. If it expires, you can request a new verification email. +email.passkey-registered.intro=A new passkey ({0}) was just added to your account. If you added it, no action is needed. +email.passkey-registered.warning=If you did not add this passkey, someone else may have access to your account. A passkey remains valid even after a password change, so change your password and remove the unrecognized passkey from your account settings, then contact support. + email.signature=Best regards,
The DigitalSanctuary Team diff --git a/src/main/resources/templates/mail/webauthn-credential-registered.html b/src/main/resources/templates/mail/webauthn-credential-registered.html new file mode 100644 index 00000000..63110f7f --- /dev/null +++ b/src/main/resources/templates/mail/webauthn-credential-registered.html @@ -0,0 +1,20 @@ + + + + + New passkey added to your account + + + + +
+ ,
+

+
+

+

+
+

+ + + diff --git a/src/test/java/com/digitalsanctuary/spring/user/listener/WebAuthnCredentialRegistrationListenerTest.java b/src/test/java/com/digitalsanctuary/spring/user/listener/WebAuthnCredentialRegistrationListenerTest.java new file mode 100644 index 00000000..a8bcaa24 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/listener/WebAuthnCredentialRegistrationListenerTest.java @@ -0,0 +1,92 @@ +package com.digitalsanctuary.spring.user.listener; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.context.ApplicationEventPublisher; +import com.digitalsanctuary.spring.user.audit.AuditEvent; +import com.digitalsanctuary.spring.user.event.WebAuthnCredentialRegisteredEvent; +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.security.WebAuthnConfigProperties; +import com.digitalsanctuary.spring.user.service.UserEmailService; + +/** + * A newly enrolled passkey is a durable new way into the account that outlives a password change, so the owner is + * told about it and the enrollment is recorded in the audit log. + */ +@DisplayName("WebAuthn Credential Registration Listener Tests") +class WebAuthnCredentialRegistrationListenerTest { + + private UserEmailService userEmailService; + private ApplicationEventPublisher eventPublisher; + private WebAuthnConfigProperties config; + private User user; + + @BeforeEach + void setUp() { + userEmailService = mock(UserEmailService.class); + eventPublisher = mock(ApplicationEventPublisher.class); + config = new WebAuthnConfigProperties(); + user = new User(); + user.setId(3L); + user.setEmail("passkey-user@test.com"); + } + + private WebAuthnCredentialRegisteredEvent event() { + return new WebAuthnCredentialRegisteredEvent(this, user, "AQIDBA", "Work Laptop"); + } + + private WebAuthnCredentialRegistrationListener listener() { + return new WebAuthnCredentialRegistrationListener(userEmailService, eventPublisher, config); + } + + @Test + @DisplayName("should record an audit event when a passkey is registered") + void shouldRecordAuditEventWhenPasskeyRegistered() { + listener().onApplicationEvent(event()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AuditEvent.class); + verify(eventPublisher).publishEvent(captor.capture()); + assertThat(captor.getValue().getAction()).isEqualTo("PasskeyRegistration"); + assertThat(captor.getValue().getActionStatus()).isEqualTo("Success"); + assertThat(captor.getValue().getUser()).isEqualTo(user); + } + + @Test + @DisplayName("should notify the account owner by email when a passkey is registered") + void shouldNotifyOwnerWhenPasskeyRegistered() { + listener().onApplicationEvent(event()); + + verify(userEmailService).sendPasskeyRegisteredNotification(user, "Work Laptop"); + } + + @Test + @DisplayName("should still record the audit event when notification email is disabled") + void shouldStillAuditWhenNotificationDisabled() { + // The email is a courtesy the operator may not want; the audit trail is not optional. + config.setNotifyOnRegistration(false); + + listener().onApplicationEvent(event()); + + verify(userEmailService, never()).sendPasskeyRegisteredNotification(any(), any()); + verify(eventPublisher).publishEvent(any(AuditEvent.class)); + } + + @Test + @DisplayName("should record the audit event even when sending the notification fails") + void shouldAuditEvenWhenNotificationFails() { + // A mail outage must not lose the security-relevant record of the enrollment. + org.mockito.Mockito.doThrow(new RuntimeException("smtp down")).when(userEmailService) + .sendPasskeyRegisteredNotification(any(), any()); + + listener().onApplicationEvent(event()); + + verify(eventPublisher).publishEvent(any(AuditEvent.class)); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/WebAuthnCredentialRegistrationEventTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/WebAuthnCredentialRegistrationEventTest.java new file mode 100644 index 00000000..b4ec74b6 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/WebAuthnCredentialRegistrationEventTest.java @@ -0,0 +1,105 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import java.time.Instant; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.security.web.webauthn.api.Bytes; +import org.springframework.security.web.webauthn.api.CredentialRecord; +import org.springframework.security.web.webauthn.api.ImmutableCredentialRecord; +import org.springframework.security.web.webauthn.api.ImmutablePublicKeyCose; +import org.springframework.security.web.webauthn.api.PublicKeyCredentialType; +import com.digitalsanctuary.spring.user.event.WebAuthnCredentialRegisteredEvent; +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.persistence.model.WebAuthnCredential; +import com.digitalsanctuary.spring.user.persistence.model.WebAuthnUserEntity; +import com.digitalsanctuary.spring.user.persistence.repository.WebAuthnCredentialRepository; +import com.digitalsanctuary.spring.user.persistence.repository.WebAuthnUserEntityRepository; + +/** + * Enrolling a passkey is a credential-altering event the account owner should hear about, and the framework does not + * own the endpoint that does it: Spring Security's {@code POST /webauthn/register} writes through + * {@code JpaUserCredentialRepository}, which is the framework's. Publishing from there catches every enrollment + * regardless of which endpoint triggered it. + *

+ * The trap this pins: {@code UserCredentialRepository.save} is also called on every successful assertion, to persist + * the updated signature count. Publishing on every save would email the user on each login. + *

+ */ +@DisplayName("WebAuthn Credential Registration Event Tests") +class WebAuthnCredentialRegistrationEventTest { + + private static final String CREDENTIAL_ID_B64 = "AQIDBA"; + + private WebAuthnCredentialRepository credentialRepository; + private WebAuthnUserEntityRepository userEntityRepository; + private ApplicationEventPublisher eventPublisher; + private WebAuthnRepositoryConfig.JpaUserCredentialRepository repository; + private User user; + + @BeforeEach + void setUp() { + credentialRepository = mock(WebAuthnCredentialRepository.class); + userEntityRepository = mock(WebAuthnUserEntityRepository.class); + eventPublisher = mock(ApplicationEventPublisher.class); + + user = new User(); + user.setId(7L); + user.setEmail("passkey-user@test.com"); + + WebAuthnUserEntity userEntity = new WebAuthnUserEntity(); + userEntity.setId("dXNlcg"); + userEntity.setName(user.getEmail()); + userEntity.setUser(user); + when(userEntityRepository.findById(any())).thenReturn(Optional.of(userEntity)); + + repository = new WebAuthnRepositoryConfig.JpaUserCredentialRepository(credentialRepository, + userEntityRepository, eventPublisher); + } + + @Test + @DisplayName("should publish a registration event when the credential is new") + void shouldPublishEventWhenCredentialIsNew() { + when(credentialRepository.findById(CREDENTIAL_ID_B64)).thenReturn(Optional.empty()); + + repository.save(credentialRecord("Work Laptop")); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(WebAuthnCredentialRegisteredEvent.class); + verify(eventPublisher).publishEvent(captor.capture()); + assertThat(captor.getValue().getUser()).isEqualTo(user); + assertThat(captor.getValue().getLabel()).isEqualTo("Work Laptop"); + } + + @Test + @DisplayName("should publish no event when an existing credential is updated") + void shouldPublishNoEventWhenCredentialIsUpdated() { + // Spring Security calls save() inside authenticate() to persist the signature count. Treating that as a + // registration would email the user on every passkey login. + WebAuthnCredential existing = new WebAuthnCredential(); + existing.setCredentialId(CREDENTIAL_ID_B64); + when(credentialRepository.findById(CREDENTIAL_ID_B64)).thenReturn(Optional.of(existing)); + + repository.save(credentialRecord("Work Laptop")); + + verify(eventPublisher, never()).publishEvent(any(WebAuthnCredentialRegisteredEvent.class)); + } + + private static CredentialRecord credentialRecord(String label) { + return ImmutableCredentialRecord.builder().credentialType(PublicKeyCredentialType.PUBLIC_KEY) + .credentialId(new Bytes(new byte[] {1, 2, 3, 4})) + .userEntityUserId(new Bytes("user".getBytes())) + .publicKey(new ImmutablePublicKeyCose(new byte[] {9, 9})) + .signatureCount(0).uvInitialized(true).backupEligible(false).backupState(false) + .created(Instant.now()).lastUsed(Instant.now()).label(label).build(); + } +}