diff --git a/CONFIG.md b/CONFIG.md
index 00eb99d..f9e078b 100644
--- a/CONFIG.md
+++ b/CONFIG.md
@@ -65,6 +65,37 @@ user:
- **Account Lockout Duration (`spring.security.accountLockoutDuration`)**: Duration (in minutes) for account lockout.
- **BCrypt Strength (`spring.security.bcryptStrength`)**: Adjust the bcrypt strength for password hashing. Default is `12`.
+## WebAuthn / Passkey Settings
+
+Provides passwordless login using biometrics, security keys, or device authentication. **HTTPS is required** for WebAuthn to function.
+
+- **Enabled (`user.webauthn.enabled`)**: Enable or disable WebAuthn/Passkey support. Defaults to `true`.
+- **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`.
+
+**Development Example:**
+```properties
+user.webauthn.enabled=true
+user.webauthn.rpId=localhost
+user.webauthn.rpName=My Application
+user.webauthn.allowedOrigins=https://localhost:8443
+```
+
+**Production Example:**
+```properties
+user.webauthn.enabled=true
+user.webauthn.rpId=example.com
+user.webauthn.rpName=My Application
+user.webauthn.allowedOrigins=https://example.com
+```
+
+**Important Notes:**
+- WebAuthn requires HTTPS in production. Generate a proper SSL certificate (Let's Encrypt, commercial CA).
+- For development, generate a self-signed certificate: `keytool -genkeypair -alias localhost -keyalg RSA -keysize 2048 -storetype PKCS12 -keystore keystore.p12 -validity 3650`
+- Configure SSL in `application.properties`: `server.ssl.enabled=true`, `server.ssl.key-store=classpath:keystore.p12`
+- Users must be authenticated before they can register a passkey. Passkeys enhance existing authentication, not replace initial registration.
+
## Mail Configuration
- **From Address (`spring.mail.fromAddress`)**: The email address used as the sender in outgoing emails.
diff --git a/build.gradle b/build.gradle
index 617d902..cf284d4 100644
--- a/build.gradle
+++ b/build.gradle
@@ -52,6 +52,10 @@ dependencies {
compileOnly 'jakarta.validation:jakarta.validation-api:3.1.1'
compileOnly 'org.springframework.retry:spring-retry:2.0.12'
+ // WebAuthn support (Passkey authentication)
+ compileOnly 'org.springframework.security:spring-security-webauthn'
+ implementation 'com.webauthn4j:webauthn4j-core:0.30.2.RELEASE'
+
// Lombok dependencies
compileOnly "org.projectlombok:lombok:$lombokVersion"
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
@@ -70,6 +74,7 @@ dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
testImplementation 'org.springframework.security:spring-security-test'
+ testImplementation 'org.springframework.security:spring-security-webauthn'
testImplementation 'org.springframework.retry:spring-retry:2.0.12'
testImplementation 'jakarta.validation:jakarta.validation-api:3.1.1'
testImplementation 'org.hibernate.validator:hibernate-validator:9.1.0.Final'
diff --git a/db-scripts/webauthn-schema.sql b/db-scripts/webauthn-schema.sql
new file mode 100644
index 0000000..66303f2
--- /dev/null
+++ b/db-scripts/webauthn-schema.sql
@@ -0,0 +1,54 @@
+-- WebAuthn (Passkey) schema for Spring Security 7.0.2
+-- These table names and column names must match Spring Security's
+-- JdbcPublicKeyCredentialUserEntityRepository and JdbcUserCredentialRepository defaults.
+--
+-- IMPORTANT: Run the application first so Hibernate creates the user_account table,
+-- then run this script.
+
+-- Table: user_entities
+-- Links WebAuthn user handles to application users.
+-- Spring Security expects: id, name, display_name
+-- We add: user_account_id (FK to user_account for efficient joins)
+CREATE TABLE IF NOT EXISTS `user_entities` (
+ `id` VARCHAR(255) NOT NULL COMMENT 'Base64url-encoded WebAuthn user handle',
+ `name` VARCHAR(255) NOT NULL COMMENT 'Username (email)',
+ `display_name` VARCHAR(255) NOT NULL COMMENT 'User full name for display',
+ `user_account_id` BIGINT(20) DEFAULT NULL COMMENT 'FK to user_account table (custom column)',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_user_entities_name` (`name`),
+ KEY `idx_user_entities_user_account_id` (`user_account_id`),
+ CONSTRAINT `fk_user_entities_user_account`
+ FOREIGN KEY (`user_account_id`)
+ REFERENCES `user_account` (`id`)
+ ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
+COMMENT='WebAuthn user entities mapped to application users';
+
+-- Table: user_credentials
+-- Stores WebAuthn credentials (public keys) for passkey authentication.
+-- All columns match Spring Security's expected schema.
+CREATE TABLE IF NOT EXISTS `user_credentials` (
+ `credential_id` BLOB NOT NULL COMMENT 'Raw credential ID from authenticator',
+ `user_entity_user_id` VARCHAR(255) NOT NULL COMMENT 'FK to user_entities.id',
+ `public_key` BLOB NOT NULL COMMENT 'COSE-encoded public key',
+ `signature_count` BIGINT(20) NOT NULL DEFAULT 0 COMMENT 'Counter to detect cloned authenticators',
+ `uv_initialized` BIT(1) NOT NULL DEFAULT 0 COMMENT 'User verification performed during registration',
+ `backup_eligible` BIT(1) DEFAULT 0 COMMENT 'Credential can be synced (iCloud Keychain, etc.)',
+ `authenticator_transports` VARCHAR(255) DEFAULT NULL COMMENT 'Supported transports: usb, nfc, ble, internal',
+ `public_key_credential_type` VARCHAR(255) DEFAULT NULL COMMENT 'Credential type (e.g. public-key)',
+ `backup_state` BIT(1) DEFAULT 0 COMMENT 'Credential is currently backed up',
+ `attestation_object` BLOB DEFAULT NULL COMMENT 'Attestation data from registration',
+ `attestation_client_data_json` BLOB DEFAULT NULL COMMENT 'Client data JSON from registration',
+ `created` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `last_used` TIMESTAMP NULL DEFAULT NULL,
+ `label` VARCHAR(255) DEFAULT NULL COMMENT 'User-friendly name (e.g., "My iPhone", "YubiKey")',
+ PRIMARY KEY (`credential_id`(255)),
+ KEY `idx_user_credentials_user_entity` (`user_entity_user_id`),
+ KEY `idx_user_credentials_created` (`created`),
+ KEY `idx_user_credentials_last_used` (`last_used`),
+ CONSTRAINT `fk_user_credentials_user_entity`
+ FOREIGN KEY (`user_entity_user_id`)
+ REFERENCES `user_entities` (`id`)
+ ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
+COMMENT='WebAuthn credentials (public keys) for passkey authentication';
diff --git a/src/main/java/com/digitalsanctuary/spring/user/api/WebAuthnManagementAPI.java b/src/main/java/com/digitalsanctuary/spring/user/api/WebAuthnManagementAPI.java
new file mode 100644
index 0000000..bc0c36d
--- /dev/null
+++ b/src/main/java/com/digitalsanctuary/spring/user/api/WebAuthnManagementAPI.java
@@ -0,0 +1,170 @@
+package com.digitalsanctuary.spring.user.api;
+
+import java.util.List;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import com.digitalsanctuary.spring.user.dto.WebAuthnCredentialInfo;
+import com.digitalsanctuary.spring.user.exceptions.WebAuthnException;
+import com.digitalsanctuary.spring.user.persistence.model.User;
+import com.digitalsanctuary.spring.user.service.UserService;
+import com.digitalsanctuary.spring.user.service.WebAuthnCredentialManagementService;
+import com.digitalsanctuary.spring.user.util.GenericResponse;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * REST API for WebAuthn credential management.
+ *
+ *
+ * This controller provides endpoints for managing WebAuthn credentials (passkeys). Authenticated users can list their
+ * registered passkeys, rename them for easier identification, and delete passkeys they no longer use.
+ *
+ *
+ *
+ * Endpoints:
+ *
+ *
+ * - GET /user/webauthn/credentials - List all passkeys for the authenticated user
+ * - GET /user/webauthn/has-credentials - Check if user has any passkeys
+ * - PUT /user/webauthn/credentials/{id}/label - Rename a passkey
+ * - DELETE /user/webauthn/credentials/{id} - Delete a passkey
+ *
+ */
+@RestController
+@RequestMapping("/user/webauthn")
+@RequiredArgsConstructor
+@Slf4j
+public class WebAuthnManagementAPI {
+
+ private final WebAuthnCredentialManagementService credentialManagementService;
+ private final UserService userService;
+
+ /**
+ * Get user's registered passkeys.
+ *
+ * @param userDetails the authenticated user details
+ * @return ResponseEntity containing list of credential information
+ */
+ @GetMapping("/credentials")
+ public ResponseEntity> getCredentials(@AuthenticationPrincipal UserDetails userDetails) {
+
+ User user = userService.findUserByEmail(userDetails.getUsername());
+ if (user == null) {
+ log.error("User not found: {}", userDetails.getUsername());
+ throw new RuntimeException("User not found");
+ }
+
+ List credentials = credentialManagementService.getUserCredentials(user);
+
+ return ResponseEntity.ok(credentials);
+ }
+
+ /**
+ * Check if user has any passkeys.
+ *
+ * @param userDetails the authenticated user details
+ * @return ResponseEntity containing true if user has passkeys, false otherwise
+ */
+ @GetMapping("/has-credentials")
+ public ResponseEntity hasCredentials(@AuthenticationPrincipal UserDetails userDetails) {
+
+ User user = userService.findUserByEmail(userDetails.getUsername());
+ if (user == null) {
+ log.error("User not found: {}", userDetails.getUsername());
+ throw new RuntimeException("User not found");
+ }
+
+ boolean hasCredentials = credentialManagementService.hasCredentials(user);
+
+ return ResponseEntity.ok(hasCredentials);
+ }
+
+ /**
+ * Rename a passkey.
+ *
+ *
+ * Updates the user-friendly label for a passkey. The label helps users identify their passkeys (e.g., "My iPhone", "Work Laptop").
+ *
+ *
+ *
+ * The label must be non-empty and no more than 255 characters.
+ *
+ *
+ * @param id the credential ID to rename
+ * @param request the rename request containing the new label
+ * @param userDetails the authenticated user details
+ * @return ResponseEntity with success message or error
+ */
+ @PutMapping("/credentials/{id}/label")
+ public ResponseEntity renameCredential(@PathVariable String id, @RequestBody @Valid RenameCredentialRequest request,
+ @AuthenticationPrincipal UserDetails userDetails) {
+
+ try {
+ User user = userService.findUserByEmail(userDetails.getUsername());
+ if (user == null) {
+ throw new WebAuthnException("User not found");
+ }
+
+ credentialManagementService.renameCredential(id, request.label(), user);
+
+ return ResponseEntity.ok(new GenericResponse("Passkey renamed successfully"));
+
+ } catch (WebAuthnException e) {
+ log.error("Failed to rename credential: {}", e.getMessage());
+ return ResponseEntity.badRequest().body(new GenericResponse(e.getMessage()));
+ }
+ }
+
+ /**
+ * Delete a passkey.
+ *
+ *
+ * Soft-deletes a passkey by marking it as disabled. Includes last-credential protection to prevent users from being
+ * locked out of their accounts.
+ *
+ *
+ *
+ * If this is the user's last passkey and they have no password, the deletion will be blocked with an error message.
+ *
+ *
+ * @param id the credential ID to delete
+ * @param userDetails the authenticated user details
+ * @return ResponseEntity with success message or error
+ */
+ @DeleteMapping("/credentials/{id}")
+ public ResponseEntity deleteCredential(@PathVariable String id, @AuthenticationPrincipal UserDetails userDetails) {
+
+ try {
+ User user = userService.findUserByEmail(userDetails.getUsername());
+ if (user == null) {
+ throw new WebAuthnException("User not found");
+ }
+
+ credentialManagementService.deleteCredential(id, user);
+
+ return ResponseEntity.ok(new GenericResponse("Passkey deleted successfully"));
+
+ } catch (WebAuthnException e) {
+ log.error("Failed to delete credential: {}", e.getMessage());
+ return ResponseEntity.badRequest().body(new GenericResponse(e.getMessage()));
+ }
+ }
+
+ /**
+ * Request DTO for renaming credential.
+ *
+ * @param label the new label (must not be blank)
+ */
+ public record RenameCredentialRequest(@NotBlank String label) {
+ }
+}
diff --git a/src/main/java/com/digitalsanctuary/spring/user/dto/WebAuthnCredentialInfo.java b/src/main/java/com/digitalsanctuary/spring/user/dto/WebAuthnCredentialInfo.java
new file mode 100644
index 0000000..924621c
--- /dev/null
+++ b/src/main/java/com/digitalsanctuary/spring/user/dto/WebAuthnCredentialInfo.java
@@ -0,0 +1,38 @@
+package com.digitalsanctuary.spring.user.dto;
+
+import java.time.Instant;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * DTO for WebAuthn credential information displayed to users.
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class WebAuthnCredentialInfo {
+
+ /** Credential ID. */
+ private String id;
+
+ /** User-friendly label. */
+ private String label;
+
+ /** Creation date. */
+ private Instant created;
+
+ /** Last authentication date. */
+ private Instant lastUsed;
+
+ /** Supported transports (usb, nfc, ble, internal). */
+ private String transports;
+
+ /** Whether credential is backup-eligible (synced passkey). */
+ private Boolean backupEligible;
+
+ /** Whether credential is currently backed up. */
+ private Boolean backupState;
+}
diff --git a/src/main/java/com/digitalsanctuary/spring/user/exceptions/WebAuthnException.java b/src/main/java/com/digitalsanctuary/spring/user/exceptions/WebAuthnException.java
new file mode 100644
index 0000000..ac3f1b4
--- /dev/null
+++ b/src/main/java/com/digitalsanctuary/spring/user/exceptions/WebAuthnException.java
@@ -0,0 +1,55 @@
+package com.digitalsanctuary.spring.user.exceptions;
+
+/**
+ * Exception thrown for WebAuthn-related errors.
+ *
+ *
+ * This is a checked exception used to signal WebAuthn-specific business logic errors such as:
+ *
+ *
+ * - Attempting to delete the last passkey when user has no password
+ * - Invalid credential label (empty, too long)
+ * - Credential not found or access denied
+ * - User not found during credential operations
+ *
+ */
+public class WebAuthnException extends Exception {
+
+ /** Serial Version UID. */
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Instantiates a new WebAuthn exception.
+ */
+ public WebAuthnException() {
+ super();
+ }
+
+ /**
+ * Instantiates a new WebAuthn exception.
+ *
+ * @param message the message
+ * @param cause the cause
+ */
+ public WebAuthnException(final String message, final Throwable cause) {
+ super(message, cause);
+ }
+
+ /**
+ * Instantiates a new WebAuthn exception.
+ *
+ * @param message the message
+ */
+ public WebAuthnException(final String message) {
+ super(message);
+ }
+
+ /**
+ * Instantiates a new WebAuthn exception.
+ *
+ * @param cause the cause
+ */
+ public WebAuthnException(final Throwable cause) {
+ super(cause);
+ }
+}
diff --git a/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/WebAuthnCredentialQueryRepository.java b/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/WebAuthnCredentialQueryRepository.java
new file mode 100644
index 0000000..7a55fe5
--- /dev/null
+++ b/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/WebAuthnCredentialQueryRepository.java
@@ -0,0 +1,155 @@
+package com.digitalsanctuary.spring.user.persistence.repository;
+
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.Base64;
+import java.util.List;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
+import com.digitalsanctuary.spring.user.dto.WebAuthnCredentialInfo;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Repository for WebAuthn credential queries and management.
+ */
+@Repository
+@RequiredArgsConstructor
+@Slf4j
+public class WebAuthnCredentialQueryRepository {
+
+ private final JdbcTemplate jdbcTemplate;
+
+ /**
+ * Get all credentials for a user.
+ *
+ * @param userId the user ID
+ * @return list of credential
+ */
+ public List findCredentialsByUserId(Long userId) {
+ String sql = """
+ SELECT c.credential_id, c.label, c.created, c.last_used,
+ c.authenticator_transports, c.backup_eligible, c.backup_state
+ FROM user_credentials c
+ JOIN user_entities ue ON c.user_entity_user_id = ue.id
+ WHERE ue.user_account_id = ?
+ ORDER BY c.created DESC
+ """;
+
+ return jdbcTemplate.query(sql, this::mapCredentialInfo, userId);
+ }
+
+ /**
+ * Check if user has any passkeys.
+ *
+ * @param userId the user ID
+ * @return true if user has at least one passkey
+ */
+ public boolean hasCredentials(Long userId) {
+ String sql = """
+ SELECT COUNT(*)
+ FROM user_credentials c
+ JOIN user_entities ue ON c.user_entity_user_id = ue.id
+ WHERE ue.user_account_id = ?
+ """;
+
+ Integer count = jdbcTemplate.queryForObject(sql, Integer.class, userId);
+ return count != null && count > 0;
+ }
+
+ /**
+ * Count credentials (used for last-credential protection).
+ *
+ * @param userId the user ID
+ * @return count of credentials
+ */
+ public long countCredentials(Long userId) {
+ String sql = """
+ SELECT COUNT(*)
+ FROM user_credentials c
+ JOIN user_entities ue ON c.user_entity_user_id = ue.id
+ WHERE ue.user_account_id = ?
+ """;
+
+ Long count = jdbcTemplate.queryForObject(sql, Long.class, userId);
+ return count != null ? count : 0L;
+ }
+
+ /**
+ * Rename a credential.
+ *
+ * @param credentialId the credential ID (base64url-encoded)
+ * @param newLabel the new label
+ * @param userId the user ID
+ * @return number of rows updated (0 if not found or access denied)
+ */
+ @Transactional
+ public int renameCredential(String credentialId, String newLabel, Long userId) {
+ byte[] credIdBytes = Base64.getUrlDecoder().decode(credentialId);
+
+ String sql = """
+ UPDATE user_credentials c
+ SET c.label = ?
+ WHERE c.credential_id = ?
+ AND EXISTS (
+ SELECT 1 FROM user_entities ue
+ WHERE ue.id = c.user_entity_user_id
+ AND ue.user_account_id = ?
+ )
+ """;
+
+ int updated = jdbcTemplate.update(sql, newLabel, credIdBytes, userId);
+ if (updated > 0) {
+ log.info("Renamed credential {} to '{}' for user {}", credentialId, newLabel, userId);
+ }
+ return updated;
+ }
+
+ /**
+ * Delete a credential.
+ *
+ * @param credentialId the credential ID (base64url-encoded)
+ * @param userId the user ID (for security check)
+ * @return number of rows deleted (0 if not found or access denied)
+ */
+ @Transactional
+ public int deleteCredential(String credentialId, Long userId) {
+ byte[] credIdBytes = Base64.getUrlDecoder().decode(credentialId);
+
+ String sql = """
+ DELETE FROM user_credentials
+ WHERE credential_id = ?
+ AND EXISTS (
+ SELECT 1 FROM user_entities ue
+ WHERE ue.id = user_entity_user_id
+ AND ue.user_account_id = ?
+ )
+ """;
+
+ int deleted = jdbcTemplate.update(sql, credIdBytes, userId);
+ if (deleted > 0) {
+ log.info("Deleted credential {} for user {}", credentialId, userId);
+ }
+ return deleted;
+ }
+
+ /**
+ * Map ResultSet to WebAuthnCredentialInfo.
+ *
+ * @param rs the ResultSet
+ * @param rowNum the row number
+ * @return the WebAuthnCredentialInfo
+ * @throws SQLException if a database access error occurs
+ */
+ private WebAuthnCredentialInfo mapCredentialInfo(ResultSet rs, int rowNum) throws SQLException {
+ byte[] credId = rs.getBytes("credential_id");
+ String credIdStr = Base64.getUrlEncoder().withoutPadding().encodeToString(credId);
+
+ return WebAuthnCredentialInfo.builder().id(credIdStr).label(rs.getString("label"))
+ .created(rs.getTimestamp("created").toInstant())
+ .lastUsed(rs.getTimestamp("last_used") != null ? rs.getTimestamp("last_used").toInstant() : null)
+ .transports(rs.getString("authenticator_transports")).backupEligible(rs.getBoolean("backup_eligible"))
+ .backupState(rs.getBoolean("backup_state")).build();
+ }
+}
diff --git a/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/WebAuthnUserEntityBridge.java b/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/WebAuthnUserEntityBridge.java
new file mode 100644
index 0000000..85c0241
--- /dev/null
+++ b/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/WebAuthnUserEntityBridge.java
@@ -0,0 +1,133 @@
+package com.digitalsanctuary.spring.user.persistence.repository;
+
+import java.nio.ByteBuffer;
+import java.util.Optional;
+import org.springframework.context.annotation.Primary;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.security.web.webauthn.api.Bytes;
+import org.springframework.security.web.webauthn.api.ImmutablePublicKeyCredentialUserEntity;
+import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity;
+import org.springframework.security.web.webauthn.management.JdbcPublicKeyCredentialUserEntityRepository;
+import org.springframework.security.web.webauthn.management.PublicKeyCredentialUserEntityRepository;
+import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
+import com.digitalsanctuary.spring.user.persistence.model.User;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ *
+ * Primary {@link PublicKeyCredentialUserEntityRepository} that bridges Spring Security's WebAuthn system with the Spring User Framework's User
+ * entity. It handles edge cases like anonymousUser and null usernames, and automatically creates WebAuthn user entities for existing application
+ * users.
+ *
+ *
+ *
+ * Marked as {@code @Primary} so Spring Security's WebAuthn filters use this bridge instead of the bare JDBC repository.
+ *
+ */
+@Repository
+@Primary
+@Slf4j
+public class WebAuthnUserEntityBridge implements PublicKeyCredentialUserEntityRepository {
+
+ private final JdbcPublicKeyCredentialUserEntityRepository delegate;
+ private final JdbcTemplate jdbcTemplate;
+ private final UserRepository userRepository;
+
+ /**
+ * Constructor creates the JDBC delegate internally to avoid circular bean dependency.
+ *
+ * @param jdbcTemplate the JDBC template
+ * @param userRepository the user repository
+ */
+ public WebAuthnUserEntityBridge(JdbcTemplate jdbcTemplate, UserRepository userRepository) {
+ this.jdbcTemplate = jdbcTemplate;
+ this.userRepository = userRepository;
+ this.delegate = new JdbcPublicKeyCredentialUserEntityRepository(jdbcTemplate);
+ }
+
+ @Override
+ public PublicKeyCredentialUserEntity findById(Bytes id) {
+ return delegate.findById(id);
+ }
+
+ @Override
+ public PublicKeyCredentialUserEntity findByUsername(String username) {
+ // Handle edge cases that can occur during login
+ if (username == null || username.isEmpty() || "anonymousUser".equals(username)) {
+ log.debug("Ignoring invalid username: {}", username);
+ return null;
+ }
+
+ // Check if user entity already exists
+ PublicKeyCredentialUserEntity existing = delegate.findByUsername(username);
+ if (existing != null) {
+ return existing;
+ }
+
+ // User entity doesn't exist yet - check if application user exists
+ User user = userRepository.findByEmail(username);
+ if (user == null) {
+ log.debug("No application user found for username: {}", username);
+ return null;
+ }
+
+ // Create WebAuthn user entity for this application user
+ return createUserEntity(user);
+ }
+
+ @Override
+ public void save(PublicKeyCredentialUserEntity userEntity) {
+ delegate.save(userEntity);
+ }
+
+ @Override
+ public void delete(Bytes id) {
+ delegate.delete(id);
+ }
+
+ /**
+ * Create user entity from User model and link to user_account via user_account_id.
+ *
+ * @param user the User entity
+ * @return the created PublicKeyCredentialUserEntity
+ */
+ @Transactional
+ public PublicKeyCredentialUserEntity createUserEntity(User user) {
+ Bytes userId = new Bytes(longToBytes(user.getId()));
+ String displayName = user.getFullName();
+
+ PublicKeyCredentialUserEntity entity = ImmutablePublicKeyCredentialUserEntity.builder().name(user.getEmail()).id(userId)
+ .displayName(displayName).build();
+
+ // Let Spring Security's JDBC repository do the standard INSERT
+ delegate.save(entity);
+
+ // Set our custom user_account_id column to link to the app user
+ jdbcTemplate.update("UPDATE user_entities SET user_account_id = ? WHERE name = ?", user.getId(), user.getEmail());
+
+ log.info("Created WebAuthn user entity for user: {}", user.getEmail());
+ return entity;
+ }
+
+ /**
+ * Find by username, returning Optional for internal use.
+ *
+ * @param username the username (email) to look up
+ * @return Optional containing the PublicKeyCredentialUserEntity, or empty if not found
+ */
+ public Optional findOptionalByUsername(String username) {
+ PublicKeyCredentialUserEntity entity = findByUsername(username);
+ return Optional.ofNullable(entity);
+ }
+
+ /**
+ * Convert Long ID to byte array for WebAuthn user ID.
+ *
+ * @param value the Long value to convert
+ * @return byte array representation of the Long
+ */
+ private byte[] longToBytes(Long value) {
+ return ByteBuffer.allocate(Long.BYTES).putLong(value).array();
+ }
+}
diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnAuthenticationSuccessHandler.java b/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnAuthenticationSuccessHandler.java
new file mode 100644
index 0000000..26d3e83
--- /dev/null
+++ b/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnAuthenticationSuccessHandler.java
@@ -0,0 +1,93 @@
+package com.digitalsanctuary.spring.user.security;
+
+import java.io.IOException;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.core.context.SecurityContextHolderStrategy;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
+import org.springframework.security.web.authentication.HttpMessageConverterAuthenticationSuccessHandler;
+import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
+import org.springframework.security.web.context.SecurityContextRepository;
+import org.springframework.security.web.webauthn.authentication.WebAuthnAuthentication;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Authentication success handler for WebAuthn (Passkey) login that converts the {@link WebAuthnAuthentication} principal from
+ * {@link org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity} to the application's {@code DSUserDetails}.
+ *
+ *
+ * Spring Security's {@code WebAuthnAuthenticationProvider} creates a {@code WebAuthnAuthentication} with {@code PublicKeyCredentialUserEntity} as the
+ * principal, discarding the {@code UserDetails} it loaded. This handler restores the full {@code DSUserDetails} as the principal so the rest of the
+ * application works identically regardless of login method (form login, OAuth2, or passkey).
+ *
+ *
+ *
+ * The handler delegates to {@link HttpMessageConverterAuthenticationSuccessHandler} to write the JSON response expected by the WebAuthn JavaScript
+ * client ({@code {"authenticated": true, "redirectUrl": "..."}}).
+ *
+ */
+@Slf4j
+public class WebAuthnAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
+
+ private final UserDetailsService userDetailsService;
+ private final AuthenticationSuccessHandler delegate;
+ private final SecurityContextRepository securityContextRepository;
+ private final SecurityContextHolderStrategy securityContextHolderStrategy;
+
+ /**
+ * Creates a new handler with the given {@code UserDetailsService} and a default {@link HttpMessageConverterAuthenticationSuccessHandler} delegate.
+ *
+ * @param userDetailsService the service to load the full user details
+ */
+ public WebAuthnAuthenticationSuccessHandler(UserDetailsService userDetailsService) {
+ this(userDetailsService, new HttpMessageConverterAuthenticationSuccessHandler());
+ }
+
+ /**
+ * Creates a new handler with the given {@code UserDetailsService} and delegate handler.
+ *
+ * @param userDetailsService the service to load the full user details
+ * @param delegate the handler to delegate to after principal conversion
+ */
+ public WebAuthnAuthenticationSuccessHandler(UserDetailsService userDetailsService, AuthenticationSuccessHandler delegate) {
+ this.userDetailsService = userDetailsService;
+ this.delegate = delegate;
+ this.securityContextRepository = new HttpSessionSecurityContextRepository();
+ this.securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy();
+ }
+
+ @Override
+ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
+ throws IOException, ServletException {
+ if (authentication instanceof WebAuthnAuthentication) {
+ String username = authentication.getName();
+ log.debug("Converting WebAuthn authentication principal to DSUserDetails for user: {}", username);
+
+ UserDetails userDetails = userDetailsService.loadUserByUsername(username);
+
+ // Create new authentication with DSUserDetails as principal, preserving authorities
+ Authentication convertedAuth = UsernamePasswordAuthenticationToken.authenticated(userDetails, null,
+ authentication.getAuthorities());
+
+ // Update SecurityContext with the converted authentication
+ SecurityContext context = securityContextHolderStrategy.getContext();
+ context.setAuthentication(convertedAuth);
+ securityContextHolderStrategy.setContext(context);
+
+ // Re-save to session (AbstractAuthenticationProcessingFilter already saved the old context)
+ securityContextRepository.saveContext(context, request, response);
+
+ log.info("WebAuthn authentication principal converted to DSUserDetails for user: {}", username);
+ delegate.onAuthenticationSuccess(request, response, convertedAuth);
+ } else {
+ delegate.onAuthenticationSuccess(request, response, authentication);
+ }
+ }
+}
diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnConfigProperties.java b/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnConfigProperties.java
new file mode 100644
index 0000000..01ca429
--- /dev/null
+++ b/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnConfigProperties.java
@@ -0,0 +1,33 @@
+package com.digitalsanctuary.spring.user.security;
+
+import java.util.Set;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import lombok.Data;
+
+/**
+ * Configuration properties for WebAuthn (Passkey) authentication.
+ */
+@Data
+@ConfigurationProperties(prefix = "user.webauthn")
+public class WebAuthnConfigProperties {
+
+ /**
+ * Relying Party ID.
+ */
+ private String rpId = "localhost";
+
+ /**
+ * Relying Party Name.
+ */
+ private String rpName = "Spring User Framework";
+
+ /**
+ * Allowed origins for WebAuthn operations.
+ */
+ private Set allowedOrigins = Set.of("https://localhost:8443");
+
+ /**
+ * Whether Passkey support is enabled.
+ */
+ private boolean enabled = 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
new file mode 100644
index 0000000..0933218
--- /dev/null
+++ b/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnRepositoryConfig.java
@@ -0,0 +1,42 @@
+package com.digitalsanctuary.spring.user.security;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.security.web.webauthn.management.JdbcUserCredentialRepository;
+import org.springframework.security.web.webauthn.management.UserCredentialRepository;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Configuration for WebAuthn repositories.
+ *
+ *
+ * Note: The {@code PublicKeyCredentialUserEntityRepository} bean is provided by
+ * {@link com.digitalsanctuary.spring.user.persistence.repository.WebAuthnUserEntityBridge} which is marked
+ * as {@code @Primary} and bridges Spring Security's WebAuthn user entities with the application's User model.
+ *
+ */
+@Slf4j
+@Configuration
+public class WebAuthnRepositoryConfig {
+
+ /**
+ *
+ * This repository handles credential CRUD operations including:
+ *
+ *
+ * - save() - Store new credentials after registration to user_credentials table
+ * - findByCredentialId() - Look up credentials during authentication
+ * - findByUserId() - Get all credentials for a user
+ * - delete() - Remove credentials from database
+ *
+ *
+ * @param jdbcTemplate for database operations
+ * @return the UserCredentialRepository instance
+ */
+ @Bean
+ public UserCredentialRepository userCredentialRepository(JdbcTemplate jdbcTemplate) {
+ log.info("Initializing WebAuthn UserCredentialRepository");
+ return new JdbcUserCredentialRepository(jdbcTemplate);
+ }
+}
diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java
index b201d1b..565d326 100644
--- a/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java
+++ b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java
@@ -3,7 +3,9 @@
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
+import java.util.HashSet;
import java.util.List;
+import java.util.Set;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.Bean;
@@ -111,6 +113,18 @@ public class WebSecurityConfig {
@Value("${user.security.rememberMe.key:#{null}}")
private String rememberMeKey;
+ @Value("${user.webauthn.enabled:true}")
+ private boolean webAuthnEnabled;
+
+ @Value("${user.webauthn.rpId:localhost}")
+ private String webAuthnRpId;
+
+ @Value("${user.webauthn.rpName:Spring User Framework}")
+ private String webAuthnRpName;
+
+ @Value("${user.webauthn.allowedOrigins:https://localhost:8443}")
+ private String webAuthnAllowedOriginsProperty;
+
private final UserDetailsService userDetailsService;
private final LoginSuccessService loginSuccessService;
@@ -158,6 +172,11 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
setupOAuth2(http);
}
+ // Configure WebAuthn (Passkey) if enabled
+ if (webAuthnEnabled) {
+ setupWebAuthn(http);
+ }
+
// Configure authorization rules based on the default action
if (DEFAULT_ACTION_DENY.equals(getDefaultAction())) {
// Allow access to unprotected URIs and require authentication for all other requests
@@ -198,6 +217,31 @@ private void setupOAuth2(HttpSecurity http) throws Exception {
}));
}
+ /**
+ * Setup WebAuthn (Passkey) specific configuration.
+ *
+ * @param http the http security object to configure
+ * @throws Exception the exception
+ */
+ private void setupWebAuthn(HttpSecurity http) throws Exception {
+ // Parse comma-separated origins into Set
+ Set allowedOrigins = Arrays.stream(webAuthnAllowedOriginsProperty.split(",")).map(String::trim)
+ .collect(java.util.stream.Collectors.toSet());
+
+ log.debug("WebSecurityConfig.setupWebAuthn: rpId={}, rpName={}, allowedOrigins={}", webAuthnRpId, webAuthnRpName, allowedOrigins);
+
+ http.webAuthn(webAuthn -> webAuthn.rpName(webAuthnRpName).rpId(webAuthnRpId).allowedOrigins(allowedOrigins)
+ .withObjectPostProcessor(
+ new org.springframework.security.config.ObjectPostProcessor() {
+ @Override
+ public O postProcess(
+ O filter) {
+ filter.setAuthenticationSuccessHandler(new WebAuthnAuthenticationSuccessHandler(userDetailsService));
+ return filter;
+ }
+ }));
+ }
+
// Commenting this out to try adding /error to the unprotected URIs list instead
// @Bean
// public WebSecurityCustomizer webSecurityCustomizer() {
diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/WebAuthnCredentialManagementService.java b/src/main/java/com/digitalsanctuary/spring/user/service/WebAuthnCredentialManagementService.java
new file mode 100644
index 0000000..893beca
--- /dev/null
+++ b/src/main/java/com/digitalsanctuary/spring/user/service/WebAuthnCredentialManagementService.java
@@ -0,0 +1,142 @@
+package com.digitalsanctuary.spring.user.service;
+
+import java.util.List;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import com.digitalsanctuary.spring.user.dto.WebAuthnCredentialInfo;
+import com.digitalsanctuary.spring.user.exceptions.WebAuthnException;
+import com.digitalsanctuary.spring.user.persistence.model.User;
+import com.digitalsanctuary.spring.user.persistence.repository.WebAuthnCredentialQueryRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Service for managing WebAuthn credentials.
+ *
+ *
+ * Handles credential listing, renaming, and deletion. It includes important safety features like last-credential
+ * protection to prevent users from being locked out of their accounts.
+ *
+ *
+ *
+ * Last-Credential Protection: The service prevents deletion of the last passkey if the user has no password,
+ * ensuring users always have a way to authenticate.
+ *
+ *
+ * @see WebAuthnCredentialQueryRepository
+ * @see WebAuthnException
+ */
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class WebAuthnCredentialManagementService {
+
+ private final WebAuthnCredentialQueryRepository credentialQueryRepository;
+
+ /**
+ * Get all credentials for a user.
+ *
+ * @param user the user to get credentials for
+ * @return list of credential information.
+ */
+ public List getUserCredentials(User user) {
+ return credentialQueryRepository.findCredentialsByUserId(user.getId());
+ }
+
+ /**
+ * Check if user has any passkeys.
+ *
+ * @param user the user to check
+ * @return true if user has at least one enabled passkey, false otherwise
+ */
+ public boolean hasCredentials(User user) {
+ return credentialQueryRepository.hasCredentials(user.getId());
+ }
+
+ /**
+ * Rename a credential label.
+ *
+ *
+ * Help users identify their passkeys (e.g., "My iPhone", "Work Laptop"). The label must be non-empty and no more than 255 characters.
+ *
+ *
+ * @param credentialId the credential ID to rename
+ * @param newLabel the new label
+ * @param user the user performing the operation
+ * @throws WebAuthnException if the credential is not found, access is denied, or the label is invalid
+ */
+ @Transactional
+ public void renameCredential(String credentialId, String newLabel, User user) throws WebAuthnException {
+ validateLabel(newLabel);
+
+ int updated = credentialQueryRepository.renameCredential(credentialId, newLabel, user.getId());
+
+ if (updated == 0) {
+ throw new WebAuthnException("Credential not found or access denied");
+ }
+
+ log.info("User {} renamed credential {}", user.getEmail(), credentialId);
+ }
+
+ /**
+ * Delete a credential with last-credential protection.
+ *
+ *
+ * Deletes a credential. This operation includes important safety logic:
+ *
+ *
+ * - If this is the user's last passkey AND the user has no password, deletion is blocked
+ * - This prevents users from being locked out of their accounts
+ * - Users must either add a password or register another passkey before deleting their last one
+ *
+ *
+ *
+ * Security: This method verifies that the credential belongs to the specified user before allowing deletion.
+ *
+ *
+ * @param credentialId the credential ID to delete
+ * @param user the user performing the operation
+ * @throws WebAuthnException if the credential is not found, access is denied, or deletion would lock out the user
+ */
+ @Transactional
+ public void deleteCredential(String credentialId, User user) throws WebAuthnException {
+ // Check if this is the last credential and user has no password
+ long enabledCount = credentialQueryRepository.countCredentials(user.getId());
+
+ if (enabledCount == 1 && (user.getPassword() == null || user.getPassword().isEmpty())) {
+ throw new WebAuthnException(
+ "Cannot delete last passkey. User would be locked out. " + "Please add a password or another passkey first.");
+ }
+
+ int updated = credentialQueryRepository.deleteCredential(credentialId, user.getId());
+
+ if (updated == 0) {
+ throw new WebAuthnException("Credential not found or access denied");
+ }
+
+ log.info("User {} deleted credential {}", user.getEmail(), credentialId);
+ }
+
+ /**
+ * Validate credential label.
+ *
+ *
+ * Ensures the label meets the following requirements:
+ *
+ *
+ * - Not null or empty (after trimming)
+ * - No more than 255 characters
+ *
+ *
+ * @param label the label to validate
+ * @throws WebAuthnException if the label is invalid
+ */
+ private void validateLabel(String label) throws WebAuthnException {
+ if (label == null || label.trim().isEmpty()) {
+ throw new WebAuthnException("Credential label cannot be empty");
+ }
+ if (label.length() > 255) {
+ throw new WebAuthnException("Credential label too long (max 255 characters)");
+ }
+ }
+}
diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/WebAuthnManagementAPITest.java b/src/test/java/com/digitalsanctuary/spring/user/api/WebAuthnManagementAPITest.java
new file mode 100644
index 0000000..d3f6d34
--- /dev/null
+++ b/src/test/java/com/digitalsanctuary/spring/user/api/WebAuthnManagementAPITest.java
@@ -0,0 +1,222 @@
+package com.digitalsanctuary.spring.user.api;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
+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.Collections;
+import java.util.List;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.userdetails.UserDetails;
+import com.digitalsanctuary.spring.user.dto.WebAuthnCredentialInfo;
+import com.digitalsanctuary.spring.user.exceptions.WebAuthnException;
+import com.digitalsanctuary.spring.user.persistence.model.User;
+import com.digitalsanctuary.spring.user.service.UserService;
+import com.digitalsanctuary.spring.user.service.WebAuthnCredentialManagementService;
+import com.digitalsanctuary.spring.user.test.annotations.ServiceTest;
+import com.digitalsanctuary.spring.user.test.fixtures.TestFixtures;
+import com.digitalsanctuary.spring.user.util.GenericResponse;
+
+@ServiceTest
+@DisplayName("WebAuthnManagementAPI Tests")
+class WebAuthnManagementAPITest {
+
+ @Mock
+ private WebAuthnCredentialManagementService credentialManagementService;
+
+ @Mock
+ private UserService userService;
+
+ @Mock
+ private UserDetails userDetails;
+
+ @InjectMocks
+ private WebAuthnManagementAPI api;
+
+ private User testUser;
+
+ @BeforeEach
+ void setUp() {
+ testUser = TestFixtures.Users.standardUser();
+ when(userDetails.getUsername()).thenReturn(testUser.getEmail());
+ when(userService.findUserByEmail(testUser.getEmail())).thenReturn(testUser);
+ }
+
+ @Nested
+ @DisplayName("GET /user/webauthn/credentials")
+ class GetCredentialsTests {
+
+ @Test
+ @DisplayName("should return credentials for authenticated user")
+ void shouldReturnCredentials() {
+ // Given
+ WebAuthnCredentialInfo cred = WebAuthnCredentialInfo.builder().id("cred-1").label("My iPhone").created(Instant.now())
+ .build();
+
+ when(credentialManagementService.getUserCredentials(testUser)).thenReturn(List.of(cred));
+
+ // When
+ ResponseEntity> response = api.getCredentials(userDetails);
+
+ // Then
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody()).hasSize(1);
+ assertThat(response.getBody().get(0).getLabel()).isEqualTo("My iPhone");
+ }
+
+ @Test
+ @DisplayName("should return empty list when no credentials")
+ void shouldReturnEmptyList() {
+ // Given
+ when(credentialManagementService.getUserCredentials(testUser)).thenReturn(Collections.emptyList());
+
+ // When
+ ResponseEntity> response = api.getCredentials(userDetails);
+
+ // Then
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("should throw when user not found")
+ void shouldThrowWhenUserNotFound() {
+ // Given
+ when(userService.findUserByEmail(testUser.getEmail())).thenReturn(null);
+
+ // When/Then
+ try {
+ api.getCredentials(userDetails);
+ } catch (RuntimeException e) {
+ assertThat(e.getMessage()).isEqualTo("User not found");
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("GET /user/webauthn/has-credentials")
+ class HasCredentialsTests {
+
+ @Test
+ @DisplayName("should return true when user has credentials")
+ void shouldReturnTrue() {
+ // Given
+ when(credentialManagementService.hasCredentials(testUser)).thenReturn(true);
+
+ // When
+ ResponseEntity response = api.hasCredentials(userDetails);
+
+ // Then
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody()).isTrue();
+ }
+
+ @Test
+ @DisplayName("should return false when user has no credentials")
+ void shouldReturnFalse() {
+ // Given
+ when(credentialManagementService.hasCredentials(testUser)).thenReturn(false);
+
+ // When
+ ResponseEntity response = api.hasCredentials(userDetails);
+
+ // Then
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody()).isFalse();
+ }
+ }
+
+ @Nested
+ @DisplayName("PUT /user/webauthn/credentials/{id}/label")
+ class RenameCredentialTests {
+
+ @Test
+ @DisplayName("should rename credential successfully")
+ void shouldRenameSuccessfully() throws WebAuthnException {
+ // Given
+ WebAuthnManagementAPI.RenameCredentialRequest request = new WebAuthnManagementAPI.RenameCredentialRequest("Work Laptop");
+
+ // When
+ ResponseEntity response = api.renameCredential("cred-1", request, userDetails);
+
+ // Then
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody().getMessage()).contains("renamed successfully");
+ verify(credentialManagementService).renameCredential("cred-1", "Work Laptop", testUser);
+ }
+
+ @Test
+ @DisplayName("should return bad request when rename fails")
+ void shouldReturnBadRequestOnFailure() throws WebAuthnException {
+ // Given
+ WebAuthnManagementAPI.RenameCredentialRequest request = new WebAuthnManagementAPI.RenameCredentialRequest("New Name");
+ doThrow(new WebAuthnException("Credential not found or access denied")).when(credentialManagementService)
+ .renameCredential(eq("cred-999"), eq("New Name"), any(User.class));
+
+ // When
+ ResponseEntity response = api.renameCredential("cred-999", request, userDetails);
+
+ // Then
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(response.getBody().getMessage()).contains("not found");
+ }
+ }
+
+ @Nested
+ @DisplayName("DELETE /user/webauthn/credentials/{id}")
+ class DeleteCredentialTests {
+
+ @Test
+ @DisplayName("should delete credential successfully")
+ void shouldDeleteSuccessfully() throws WebAuthnException {
+ // When
+ ResponseEntity response = api.deleteCredential("cred-1", userDetails);
+
+ // Then
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody().getMessage()).contains("deleted successfully");
+ verify(credentialManagementService).deleteCredential("cred-1", testUser);
+ }
+
+ @Test
+ @DisplayName("should return bad request when delete fails")
+ void shouldReturnBadRequestOnFailure() throws WebAuthnException {
+ // Given
+ doThrow(new WebAuthnException("Cannot delete last passkey")).when(credentialManagementService).deleteCredential(eq("cred-1"),
+ any(User.class));
+
+ // When
+ ResponseEntity response = api.deleteCredential("cred-1", userDetails);
+
+ // Then
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(response.getBody().getMessage()).contains("Cannot delete last passkey");
+ }
+
+ @Test
+ @DisplayName("should return bad request when user not found")
+ void shouldReturnBadRequestWhenUserNotFound() throws WebAuthnException {
+ // Given
+ when(userService.findUserByEmail(testUser.getEmail())).thenReturn(null);
+
+ // When
+ ResponseEntity response = api.deleteCredential("cred-1", userDetails);
+
+ // Then
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(response.getBody().getMessage()).contains("User not found");
+ verify(credentialManagementService, never()).deleteCredential(any(), any());
+ }
+ }
+}
diff --git a/src/test/java/com/digitalsanctuary/spring/user/persistence/repository/WebAuthnUserEntityBridgeTest.java b/src/test/java/com/digitalsanctuary/spring/user/persistence/repository/WebAuthnUserEntityBridgeTest.java
new file mode 100644
index 0000000..a4ac68f
--- /dev/null
+++ b/src/test/java/com/digitalsanctuary/spring/user/persistence/repository/WebAuthnUserEntityBridgeTest.java
@@ -0,0 +1,108 @@
+package com.digitalsanctuary.spring.user.persistence.repository;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import java.util.Optional;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity;
+import com.digitalsanctuary.spring.user.persistence.model.User;
+import com.digitalsanctuary.spring.user.test.annotations.ServiceTest;
+import com.digitalsanctuary.spring.user.test.fixtures.TestFixtures;
+
+@ServiceTest
+@DisplayName("WebAuthnUserEntityBridge Tests")
+class WebAuthnUserEntityBridgeTest {
+
+ @Mock
+ private JdbcTemplate jdbcTemplate;
+
+ @Mock
+ private UserRepository userRepository;
+
+ @Mock
+ private PublicKeyCredentialUserEntity existingEntity;
+
+ private WebAuthnUserEntityBridge bridge;
+
+ private User testUser;
+
+ @BeforeEach
+ void setUp() {
+ testUser = TestFixtures.Users.standardUser();
+ bridge = new WebAuthnUserEntityBridge(jdbcTemplate, userRepository);
+ }
+
+ @Nested
+ @DisplayName("Find By Username")
+ class FindByUsernameTests {
+
+ @Test
+ @DisplayName("should return null for null username")
+ void shouldReturnNullForNull() {
+ // When
+ PublicKeyCredentialUserEntity result = bridge.findByUsername(null);
+
+ // Then
+ assertThat(result).isNull();
+ }
+
+ @Test
+ @DisplayName("should return null for empty username")
+ void shouldReturnNullForEmpty() {
+ // When
+ PublicKeyCredentialUserEntity result = bridge.findByUsername("");
+
+ // Then
+ assertThat(result).isNull();
+ }
+
+ @Test
+ @DisplayName("should return null for anonymousUser")
+ void shouldReturnNullForAnonymousUser() {
+ // When
+ PublicKeyCredentialUserEntity result = bridge.findByUsername("anonymousUser");
+
+ // Then
+ assertThat(result).isNull();
+ }
+
+ @Test
+ @DisplayName("should return empty when no application user found")
+ void shouldReturnEmptyWhenNoUser() {
+ // Given
+ when(userRepository.findByEmail("unknown@test.com")).thenReturn(null);
+
+ // When
+ Optional result = bridge.findOptionalByUsername("unknown@test.com");
+
+ // Then
+ assertThat(result).isEmpty();
+ }
+ }
+
+ @Nested
+ @DisplayName("Create User Entity")
+ class CreateUserEntityTests {
+
+ @Test
+ @DisplayName("should create entity with correct name and display name")
+ void shouldCreateEntityWithCorrectFields() {
+ // When
+ PublicKeyCredentialUserEntity entity = bridge.createUserEntity(testUser);
+
+ // Then
+ assertThat(entity.getName()).isEqualTo(testUser.getEmail());
+ assertThat(entity.getDisplayName()).isEqualTo(testUser.getFullName());
+ assertThat(entity.getId()).isNotNull();
+ }
+ }
+}
diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/WebAuthnAuthenticationSuccessHandlerTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/WebAuthnAuthenticationSuccessHandlerTest.java
new file mode 100644
index 0000000..e25ad6b
--- /dev/null
+++ b/src/test/java/com/digitalsanctuary/spring/user/security/WebAuthnAuthenticationSuccessHandlerTest.java
@@ -0,0 +1,158 @@
+package com.digitalsanctuary.spring.user.security;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import java.util.Collection;
+import java.util.Set;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
+import org.springframework.security.web.webauthn.api.Bytes;
+import org.springframework.security.web.webauthn.api.ImmutablePublicKeyCredentialUserEntity;
+import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity;
+import org.springframework.security.web.webauthn.authentication.WebAuthnAuthentication;
+import com.digitalsanctuary.spring.user.persistence.model.User;
+import com.digitalsanctuary.spring.user.service.DSUserDetails;
+import com.digitalsanctuary.spring.user.test.annotations.ServiceTest;
+import com.digitalsanctuary.spring.user.test.fixtures.TestFixtures;
+
+@ServiceTest
+@DisplayName("WebAuthnAuthenticationSuccessHandler Tests")
+class WebAuthnAuthenticationSuccessHandlerTest {
+
+ @Mock
+ private UserDetailsService userDetailsService;
+
+ @Mock
+ private AuthenticationSuccessHandler delegate;
+
+ private WebAuthnAuthenticationSuccessHandler handler;
+
+ private MockHttpServletRequest request;
+ private MockHttpServletResponse response;
+ private User testUser;
+
+ @BeforeEach
+ void setUp() {
+ testUser = TestFixtures.Users.standardUser();
+ handler = new WebAuthnAuthenticationSuccessHandler(userDetailsService, delegate);
+ request = new MockHttpServletRequest();
+ response = new MockHttpServletResponse();
+ SecurityContextHolder.clearContext();
+ }
+
+ @Nested
+ @DisplayName("WebAuthn Authentication Conversion")
+ class WebAuthnConversionTests {
+
+ @Test
+ @DisplayName("should convert WebAuthn principal to DSUserDetails")
+ void shouldConvertWebAuthnPrincipalToDSUserDetails() throws Exception {
+ // Given
+ Collection authorities = Set.of(new SimpleGrantedAuthority("ROLE_USER"));
+ PublicKeyCredentialUserEntity userEntity = ImmutablePublicKeyCredentialUserEntity.builder()
+ .name(testUser.getEmail()).id(new Bytes(new byte[] {1, 2, 3})).displayName(testUser.getFullName()).build();
+
+ WebAuthnAuthentication webAuthnAuth = new WebAuthnAuthentication(userEntity, authorities);
+
+ DSUserDetails dsUserDetails = new DSUserDetails(testUser, authorities);
+ when(userDetailsService.loadUserByUsername(testUser.getEmail())).thenReturn(dsUserDetails);
+
+ // When
+ handler.onAuthenticationSuccess(request, response, webAuthnAuth);
+
+ // Then - delegate should be called with converted authentication
+ ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authentication.class);
+ verify(delegate).onAuthenticationSuccess(org.mockito.ArgumentMatchers.eq(request), org.mockito.ArgumentMatchers.eq(response),
+ authCaptor.capture());
+
+ Authentication convertedAuth = authCaptor.getValue();
+ assertThat(convertedAuth).isInstanceOf(UsernamePasswordAuthenticationToken.class);
+ assertThat(convertedAuth.getPrincipal()).isInstanceOf(DSUserDetails.class);
+ assertThat(((DSUserDetails) convertedAuth.getPrincipal()).getUser()).isEqualTo(testUser);
+ }
+
+ @Test
+ @DisplayName("should update SecurityContext with converted authentication")
+ void shouldUpdateSecurityContext() throws Exception {
+ // Given
+ Collection authorities = Set.of(new SimpleGrantedAuthority("ROLE_USER"));
+ PublicKeyCredentialUserEntity userEntity = ImmutablePublicKeyCredentialUserEntity.builder()
+ .name(testUser.getEmail()).id(new Bytes(new byte[] {1, 2, 3})).displayName(testUser.getFullName()).build();
+
+ WebAuthnAuthentication webAuthnAuth = new WebAuthnAuthentication(userEntity, authorities);
+
+ DSUserDetails dsUserDetails = new DSUserDetails(testUser, authorities);
+ when(userDetailsService.loadUserByUsername(testUser.getEmail())).thenReturn(dsUserDetails);
+
+ // When
+ handler.onAuthenticationSuccess(request, response, webAuthnAuth);
+
+ // Then - SecurityContext should have DSUserDetails as principal
+ Authentication contextAuth = SecurityContextHolder.getContext().getAuthentication();
+ assertThat(contextAuth.getPrincipal()).isInstanceOf(DSUserDetails.class);
+ assertThat(((DSUserDetails) contextAuth.getPrincipal()).getUser().getEmail()).isEqualTo(testUser.getEmail());
+ }
+
+ @Test
+ @DisplayName("should preserve authorities from WebAuthn authentication")
+ void shouldPreserveAuthorities() throws Exception {
+ // Given
+ Collection authorities = Set.of(new SimpleGrantedAuthority("ROLE_USER"),
+ new SimpleGrantedAuthority("ROLE_ADMIN"));
+ PublicKeyCredentialUserEntity userEntity = ImmutablePublicKeyCredentialUserEntity.builder()
+ .name(testUser.getEmail()).id(new Bytes(new byte[] {1, 2, 3})).displayName(testUser.getFullName()).build();
+
+ WebAuthnAuthentication webAuthnAuth = new WebAuthnAuthentication(userEntity, authorities);
+
+ DSUserDetails dsUserDetails = new DSUserDetails(testUser, authorities);
+ when(userDetailsService.loadUserByUsername(testUser.getEmail())).thenReturn(dsUserDetails);
+
+ // When
+ handler.onAuthenticationSuccess(request, response, webAuthnAuth);
+
+ // Then
+ ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authentication.class);
+ verify(delegate).onAuthenticationSuccess(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), authCaptor.capture());
+
+ Authentication convertedAuth = authCaptor.getValue();
+ assertThat(convertedAuth.getAuthorities()).hasSize(authorities.size());
+ assertThat(convertedAuth.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList())
+ .containsExactlyInAnyOrder("ROLE_USER", "ROLE_ADMIN");
+ }
+ }
+
+ @Nested
+ @DisplayName("Non-WebAuthn Authentication")
+ class NonWebAuthnTests {
+
+ @Test
+ @DisplayName("should pass through non-WebAuthn authentication unchanged")
+ void shouldPassThroughNonWebAuthnAuth() throws Exception {
+ // Given
+ DSUserDetails dsUserDetails = new DSUserDetails(testUser);
+ UsernamePasswordAuthenticationToken formAuth = UsernamePasswordAuthenticationToken.authenticated(dsUserDetails, null,
+ Set.of(new SimpleGrantedAuthority("ROLE_USER")));
+
+ // When
+ handler.onAuthenticationSuccess(request, response, formAuth);
+
+ // Then - delegate should be called with original authentication
+ verify(delegate).onAuthenticationSuccess(request, response, formAuth);
+ }
+ }
+}
diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/WebAuthnCredentialManagementServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/WebAuthnCredentialManagementServiceTest.java
new file mode 100644
index 0000000..717bc31
--- /dev/null
+++ b/src/test/java/com/digitalsanctuary/spring/user/service/WebAuthnCredentialManagementServiceTest.java
@@ -0,0 +1,253 @@
+package com.digitalsanctuary.spring.user.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+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.Collections;
+import java.util.List;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import com.digitalsanctuary.spring.user.dto.WebAuthnCredentialInfo;
+import com.digitalsanctuary.spring.user.exceptions.WebAuthnException;
+import com.digitalsanctuary.spring.user.persistence.model.User;
+import com.digitalsanctuary.spring.user.persistence.repository.WebAuthnCredentialQueryRepository;
+import com.digitalsanctuary.spring.user.test.annotations.ServiceTest;
+import com.digitalsanctuary.spring.user.test.fixtures.TestFixtures;
+
+@ServiceTest
+@DisplayName("WebAuthnCredentialManagementService Tests")
+class WebAuthnCredentialManagementServiceTest {
+
+ @Mock
+ private WebAuthnCredentialQueryRepository credentialQueryRepository;
+
+ @InjectMocks
+ private WebAuthnCredentialManagementService service;
+
+ private User testUser;
+
+ @BeforeEach
+ void setUp() {
+ testUser = TestFixtures.Users.standardUser();
+ }
+
+ @Nested
+ @DisplayName("Get User Credentials")
+ class GetUserCredentialsTests {
+
+ @Test
+ @DisplayName("should return credentials for user")
+ void shouldReturnCredentialsForUser() {
+ // Given
+ WebAuthnCredentialInfo cred = WebAuthnCredentialInfo.builder().id("cred-123").label("My iPhone")
+ .created(Instant.now()).build();
+
+ when(credentialQueryRepository.findCredentialsByUserId(testUser.getId())).thenReturn(List.of(cred));
+
+ // When
+ List credentials = service.getUserCredentials(testUser);
+
+ // Then
+ assertThat(credentials).hasSize(1);
+ assertThat(credentials.get(0).getLabel()).isEqualTo("My iPhone");
+ assertThat(credentials.get(0).getId()).isEqualTo("cred-123");
+ verify(credentialQueryRepository).findCredentialsByUserId(testUser.getId());
+ }
+
+ @Test
+ @DisplayName("should return empty list when user has no credentials")
+ void shouldReturnEmptyListWhenNoCredentials() {
+ // Given
+ when(credentialQueryRepository.findCredentialsByUserId(testUser.getId())).thenReturn(Collections.emptyList());
+
+ // When
+ List credentials = service.getUserCredentials(testUser);
+
+ // Then
+ assertThat(credentials).isEmpty();
+ }
+ }
+
+ @Nested
+ @DisplayName("Has Credentials")
+ class HasCredentialsTests {
+
+ @Test
+ @DisplayName("should return true when user has credentials")
+ void shouldReturnTrueWhenHasCredentials() {
+ // Given
+ when(credentialQueryRepository.hasCredentials(testUser.getId())).thenReturn(true);
+
+ // When
+ boolean result = service.hasCredentials(testUser);
+
+ // Then
+ assertThat(result).isTrue();
+ }
+
+ @Test
+ @DisplayName("should return false when user has no credentials")
+ void shouldReturnFalseWhenNoCredentials() {
+ // Given
+ when(credentialQueryRepository.hasCredentials(testUser.getId())).thenReturn(false);
+
+ // When
+ boolean result = service.hasCredentials(testUser);
+
+ // Then
+ assertThat(result).isFalse();
+ }
+ }
+
+ @Nested
+ @DisplayName("Rename Credential")
+ class RenameCredentialTests {
+
+ @Test
+ @DisplayName("should rename credential successfully")
+ void shouldRenameCredentialSuccessfully() throws WebAuthnException {
+ // Given
+ when(credentialQueryRepository.renameCredential("cred-123", "Work Laptop", testUser.getId())).thenReturn(1);
+
+ // When
+ service.renameCredential("cred-123", "Work Laptop", testUser);
+
+ // Then
+ verify(credentialQueryRepository).renameCredential("cred-123", "Work Laptop", testUser.getId());
+ }
+
+ @Test
+ @DisplayName("should throw when credential not found")
+ void shouldThrowWhenCredentialNotFound() {
+ // Given
+ when(credentialQueryRepository.renameCredential("cred-999", "New Name", testUser.getId())).thenReturn(0);
+
+ // When/Then
+ assertThatThrownBy(() -> service.renameCredential("cred-999", "New Name", testUser)).isInstanceOf(WebAuthnException.class)
+ .hasMessageContaining("not found");
+ }
+
+ @Test
+ @DisplayName("should throw when label is null")
+ void shouldThrowWhenLabelIsNull() {
+ // When/Then
+ assertThatThrownBy(() -> service.renameCredential("cred-123", null, testUser)).isInstanceOf(WebAuthnException.class)
+ .hasMessageContaining("cannot be empty");
+
+ verify(credentialQueryRepository, never()).renameCredential(anyString(), anyString(), anyLong());
+ }
+
+ @Test
+ @DisplayName("should throw when label is empty")
+ void shouldThrowWhenLabelIsEmpty() {
+ // When/Then
+ assertThatThrownBy(() -> service.renameCredential("cred-123", "", testUser)).isInstanceOf(WebAuthnException.class)
+ .hasMessageContaining("cannot be empty");
+
+ verify(credentialQueryRepository, never()).renameCredential(anyString(), anyString(), anyLong());
+ }
+
+ @Test
+ @DisplayName("should throw when label is blank")
+ void shouldThrowWhenLabelIsBlank() {
+ // When/Then
+ assertThatThrownBy(() -> service.renameCredential("cred-123", " ", testUser)).isInstanceOf(WebAuthnException.class)
+ .hasMessageContaining("cannot be empty");
+ }
+
+ @Test
+ @DisplayName("should throw when label exceeds 255 characters")
+ void shouldThrowWhenLabelTooLong() {
+ // Given
+ String longLabel = "a".repeat(256);
+
+ // When/Then
+ assertThatThrownBy(() -> service.renameCredential("cred-123", longLabel, testUser)).isInstanceOf(WebAuthnException.class)
+ .hasMessageContaining("too long");
+
+ verify(credentialQueryRepository, never()).renameCredential(anyString(), anyString(), anyLong());
+ }
+ }
+
+ @Nested
+ @DisplayName("Delete Credential")
+ class DeleteCredentialTests {
+
+ @Test
+ @DisplayName("should delete credential when user has multiple passkeys")
+ void shouldDeleteWhenMultiplePasskeys() throws WebAuthnException {
+ // Given
+ when(credentialQueryRepository.countCredentials(testUser.getId())).thenReturn(2L);
+ when(credentialQueryRepository.deleteCredential("cred-123", testUser.getId())).thenReturn(1);
+
+ // When
+ service.deleteCredential("cred-123", testUser);
+
+ // Then
+ verify(credentialQueryRepository).deleteCredential("cred-123", testUser.getId());
+ }
+
+ @Test
+ @DisplayName("should delete last credential when user has a password")
+ void shouldDeleteLastCredentialWhenUserHasPassword() throws WebAuthnException {
+ // Given - user has password set (from TestFixtures)
+ when(credentialQueryRepository.countCredentials(testUser.getId())).thenReturn(1L);
+ when(credentialQueryRepository.deleteCredential("cred-123", testUser.getId())).thenReturn(1);
+
+ // When
+ service.deleteCredential("cred-123", testUser);
+
+ // Then
+ verify(credentialQueryRepository).deleteCredential("cred-123", testUser.getId());
+ }
+
+ @Test
+ @DisplayName("should block deletion of last passkey when user has no password")
+ void shouldBlockDeletionOfLastPasskeyWithoutPassword() {
+ // Given
+ testUser.setPassword(null);
+ when(credentialQueryRepository.countCredentials(testUser.getId())).thenReturn(1L);
+
+ // When/Then
+ assertThatThrownBy(() -> service.deleteCredential("cred-123", testUser)).isInstanceOf(WebAuthnException.class)
+ .hasMessageContaining("Cannot delete last passkey");
+
+ verify(credentialQueryRepository, never()).deleteCredential(anyString(), anyLong());
+ }
+
+ @Test
+ @DisplayName("should block deletion of last passkey when user has empty password")
+ void shouldBlockDeletionOfLastPasskeyWithEmptyPassword() {
+ // Given
+ testUser.setPassword("");
+ when(credentialQueryRepository.countCredentials(testUser.getId())).thenReturn(1L);
+
+ // When/Then
+ assertThatThrownBy(() -> service.deleteCredential("cred-123", testUser)).isInstanceOf(WebAuthnException.class)
+ .hasMessageContaining("Cannot delete last passkey");
+
+ verify(credentialQueryRepository, never()).deleteCredential(anyString(), anyLong());
+ }
+
+ @Test
+ @DisplayName("should throw when credential not found")
+ void shouldThrowWhenCredentialNotFound() {
+ // Given
+ when(credentialQueryRepository.countCredentials(testUser.getId())).thenReturn(2L);
+ when(credentialQueryRepository.deleteCredential("cred-999", testUser.getId())).thenReturn(0);
+
+ // When/Then
+ assertThatThrownBy(() -> service.deleteCredential("cred-999", testUser)).isInstanceOf(WebAuthnException.class)
+ .hasMessageContaining("not found");
+ }
+ }
+}
diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties
index c3f0334..9c6e6f7 100644
--- a/src/test/resources/application.properties
+++ b/src/test/resources/application.properties
@@ -20,6 +20,9 @@ user.tokenService.expirationInMinutes=60
user.roles.default=ROLE_USER
user.audit.enabled=false
+# WebAuthn (Passkey) - disabled by default for tests
+user.webauthn.enabled=false
+
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=MySQL