From ce3fbc0ddb233cbde47b4eb34f1462d7d2937781 Mon Sep 17 00:00:00 2001 From: Victor Stanchev Date: Sun, 13 Apr 2025 16:25:33 +0300 Subject: [PATCH 01/21] Add last modified date tracking to User entity --- .../com/helioauth/passkeys/api/domain/User.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/main/java/com/helioauth/passkeys/api/domain/User.java b/src/main/java/com/helioauth/passkeys/api/domain/User.java index 587e63f..6a32ce2 100644 --- a/src/main/java/com/helioauth/passkeys/api/domain/User.java +++ b/src/main/java/com/helioauth/passkeys/api/domain/User.java @@ -21,6 +21,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; @@ -32,6 +34,7 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; +import java.time.Instant; import java.util.List; import java.util.UUID; @@ -57,6 +60,16 @@ public class User { @Column private String displayName; + @CreatedDate + @Temporal(TemporalType.TIMESTAMP) + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @LastModifiedDate + @Temporal(TemporalType.TIMESTAMP) + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private List userCredentials; From 026e90f4ab98a073cbc41415809f7df40992b270 Mon Sep 17 00:00:00 2001 From: Victor Stanchev Date: Sun, 13 Apr 2025 16:44:11 +0300 Subject: [PATCH 02/21] feat: add relying party name and hostname fields --- .../helioauth/passkeys/api/domain/ClientApplication.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/com/helioauth/passkeys/api/domain/ClientApplication.java b/src/main/java/com/helioauth/passkeys/api/domain/ClientApplication.java index eca6017..9b23357 100644 --- a/src/main/java/com/helioauth/passkeys/api/domain/ClientApplication.java +++ b/src/main/java/com/helioauth/passkeys/api/domain/ClientApplication.java @@ -63,6 +63,12 @@ public class ClientApplication { @Column(name = "api_key", nullable = false) private String apiKey; + @Column(name = "relying_party_name", nullable = true) + private String relyingPartyName; + + @Column(name = "relying_party_hostname", nullable = true) + private String relyingPartyHostname; + @CreatedDate @Temporal(TemporalType.TIMESTAMP) @Column(name = "created_at", nullable = false) From 1c380b41e3be538c6f27dba947ab760a974ebc3a Mon Sep 17 00:00:00 2001 From: Victor Stanchev Date: Sun, 13 Apr 2025 17:04:23 +0300 Subject: [PATCH 03/21] Remove unnecessary nullable annotations in User entity --- src/main/java/com/helioauth/passkeys/api/domain/User.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/helioauth/passkeys/api/domain/User.java b/src/main/java/com/helioauth/passkeys/api/domain/User.java index 6a32ce2..ed10dc0 100644 --- a/src/main/java/com/helioauth/passkeys/api/domain/User.java +++ b/src/main/java/com/helioauth/passkeys/api/domain/User.java @@ -34,6 +34,8 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; +import jakarta.persistence.Temporal; +import jakarta.persistence.TemporalType; import java.time.Instant; import java.util.List; import java.util.UUID; @@ -62,18 +64,18 @@ public class User { @CreatedDate @Temporal(TemporalType.TIMESTAMP) - @Column(name = "created_at", nullable = false) + @Column(name = "created_at") private Instant createdAt; @LastModifiedDate @Temporal(TemporalType.TIMESTAMP) - @Column(name = "updated_at", nullable = false) + @Column(name = "updated_at") private Instant updatedAt; @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private List userCredentials; @ManyToOne(targetEntity = ClientApplication.class, fetch = jakarta.persistence.FetchType.LAZY) - @JoinColumn(name = "application_id", nullable = true) + @JoinColumn(name = "application_id") private ClientApplication clientApplication; } From 2ecd64976fc99c738d3ef26feed5551e55f923a8 Mon Sep 17 00:00:00 2001 From: "Victor Stanchev (aider)" Date: Mon, 14 Apr 2025 09:17:54 +0300 Subject: [PATCH 04/21] feat: Implemented reading relying party config from current client application --- .../api/controller/CredentialsController.java | 21 +++- .../api/service/UserSignupService.java | 16 ++- .../api/service/WebAuthnAuthenticator.java | 77 +++++++++++- .../controller/CredentialsControllerTest.java | 5 +- .../service/UserCredentialManagerTest.java | 16 ++- .../api/service/UserSignupServiceTest.java | 69 +++++++++-- .../service/WebAuthnAuthenticatorTest.java | 110 +++++++++++------- 7 files changed, 243 insertions(+), 71 deletions(-) diff --git a/src/main/java/com/helioauth/passkeys/api/controller/CredentialsController.java b/src/main/java/com/helioauth/passkeys/api/controller/CredentialsController.java index 3879c68..80c1f49 100644 --- a/src/main/java/com/helioauth/passkeys/api/controller/CredentialsController.java +++ b/src/main/java/com/helioauth/passkeys/api/controller/CredentialsController.java @@ -17,6 +17,7 @@ package com.helioauth.passkeys.api.controller; import com.fasterxml.jackson.core.JsonProcessingException; +import com.helioauth.passkeys.api.domain.ClientApplication; import com.helioauth.passkeys.api.generated.api.SignInApi; import com.helioauth.passkeys.api.generated.api.SignUpApi; import com.helioauth.passkeys.api.generated.models.SignInFinishRequest; @@ -34,6 +35,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; @@ -53,9 +55,22 @@ public class CredentialsController implements SignUpApi, SignInApi { private final UserSignInService userSignInService; private final UserSignupService userSignupService; - public ResponseEntity postSignupStart(@RequestBody @Valid SignUpStartRequest request) { + public ResponseEntity postSignupStart(@RequestBody @Valid SignUpStartRequest request, Authentication authentication) { + if (authentication == null || !(authentication.getPrincipal() instanceof ClientApplication)) { + log.error("Signup start request received without valid ClientApplication authentication."); + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Client application not authenticated"); + } + ClientApplication clientApp = (ClientApplication) authentication.getPrincipal(); + + String rpId = clientApp.getRelyingPartyHostname(); + + if (rpId == null || rpId.isBlank()) { + log.error("Authenticated ClientApplication (ID: {}) is missing a valid relyingPartyHostname.", clientApp.getId()); + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Client application configuration error: Missing RP hostname"); + } + return ResponseEntity.ok( - userSignupService.startRegistration(request.getName()) + userSignupService.startRegistration(request.getName(), rpId) ); } @@ -88,4 +103,4 @@ public ResponseEntity finishSignInCredential(@RequestBody throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Sign in failed"); } } -} \ No newline at end of file +} diff --git a/src/main/java/com/helioauth/passkeys/api/service/UserSignupService.java b/src/main/java/com/helioauth/passkeys/api/service/UserSignupService.java index ff10ccd..802ea0b 100644 --- a/src/main/java/com/helioauth/passkeys/api/service/UserSignupService.java +++ b/src/main/java/com/helioauth/passkeys/api/service/UserSignupService.java @@ -28,6 +28,8 @@ import com.helioauth.passkeys.api.service.dto.CredentialRegistrationResult; import com.helioauth.passkeys.api.service.exception.SignUpFailedException; import com.helioauth.passkeys.api.service.exception.UsernameAlreadyRegisteredException; +// Import ByteArray if needed, or rely on WebAuthnAuthenticator.generateRandom() +import com.yubico.webauthn.data.ByteArray; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -49,13 +51,21 @@ public class UserSignupService { private final UserCredentialMapper userCredentialMapper; private final RegistrationResponseMapper registrationResponseMapper; - public SignUpStartResponse startRegistration(String name) { + public SignUpStartResponse startRegistration(String name, String rpId) { + // Check if a user with this name already exists + if (userRepository.findByName(name).isPresent()) { + log.warn("Attempted to start registration for already existing username: {}", name); + throw new UsernameAlreadyRegisteredException(); + } + try { + // If user does not exist, proceed with generating ID and starting registration + ByteArray userId = WebAuthnAuthenticator.generateRandom(); return registrationResponseMapper.toSignUpStartResponse( - webAuthnAuthenticator.startRegistration(name) + webAuthnAuthenticator.startRegistration(name, userId, rpId) ); } catch (JsonProcessingException e) { - log.error("Register Credential failed", e); + log.error("Register Credential failed for user '{}' and rpId '{}'", name, rpId, e); throw new SignUpFailedException(); } } diff --git a/src/main/java/com/helioauth/passkeys/api/service/WebAuthnAuthenticator.java b/src/main/java/com/helioauth/passkeys/api/service/WebAuthnAuthenticator.java index 14ef714..f633861 100644 --- a/src/main/java/com/helioauth/passkeys/api/service/WebAuthnAuthenticator.java +++ b/src/main/java/com/helioauth/passkeys/api/service/WebAuthnAuthenticator.java @@ -18,6 +18,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.github.benmanes.caffeine.cache.Cache; +import com.helioauth.passkeys.api.config.properties.WebAuthnRelyingPartyProperties; // Added import import com.helioauth.passkeys.api.mapper.CredentialRegistrationResultMapper; import com.helioauth.passkeys.api.mapper.RegistrationResponseMapper; import com.helioauth.passkeys.api.service.dto.AssertionStartResult; @@ -25,6 +26,7 @@ import com.helioauth.passkeys.api.service.dto.CredentialRegistrationResult; import com.helioauth.passkeys.api.service.exception.CredentialAssertionFailedException; import com.helioauth.passkeys.api.service.exception.CredentialRegistrationFailedException; +import com.helioauth.passkeys.api.webauthn.DatabaseCredentialRepository; // Added import import com.yubico.webauthn.AssertionRequest; import com.yubico.webauthn.AssertionResult; import com.yubico.webauthn.FinishAssertionOptions; @@ -42,6 +44,7 @@ import com.yubico.webauthn.data.ClientRegistrationExtensionOutputs; import com.yubico.webauthn.data.PublicKeyCredential; import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions; +import com.yubico.webauthn.data.RelyingPartyIdentity; // Added import import com.yubico.webauthn.data.ResidentKeyRequirement; import com.yubico.webauthn.data.UserIdentity; import com.yubico.webauthn.exception.AssertionFailedException; @@ -62,10 +65,13 @@ @RequiredArgsConstructor public class WebAuthnAuthenticator { - private final RelyingParty relyingParty; + // Removed: private final RelyingParty relyingParty; - private final CredentialRegistrationResultMapper credentialRegistrationResultMapper; + // Added dependencies needed to build RelyingParty instances + private final DatabaseCredentialRepository databaseCredentialRepository; + private final WebAuthnRelyingPartyProperties relyingPartyProperties; + private final CredentialRegistrationResultMapper credentialRegistrationResultMapper; private final Cache webAuthnRequestCache; private static final SecureRandom random = new SecureRandom(); @@ -74,10 +80,33 @@ public class WebAuthnAuthenticator { public AssertionStartResult startRegistration(String name) throws JsonProcessingException { ByteArray id = generateRandom(); + // This existing method now implicitly calls the new 3-arg method via the 2-arg one below return startRegistration(name, id); } public AssertionStartResult startRegistration(String name, ByteArray userId) throws JsonProcessingException { + // Delegate to the new 3-argument method, using the configured default RP ID from properties + return startRegistration(name, userId, relyingPartyProperties.getHostname()); + } + + /** + * Start registration for a given user and RP Hostname. + * Note: Currently, the rpHostname parameter is not used to dynamically change the RelyingParty, + * as the relyingParty bean is configured globally. This parameter is added for potential future use + * (e.g., associating the request with a specific RP context). + * + * @param name The username. + * @param userId The user's unique ID. + * @param rpHostname The hostname of the relying party (currently informational). + * @return AssertionStartResult containing the request ID and creation options. + * @throws JsonProcessingException If JSON processing fails. + */ + public AssertionStartResult startRegistration(String name, ByteArray userId, String rpHostname) throws JsonProcessingException { + log.debug("Starting registration for user '{}' with id '{}' for RP '{}'", name, userId.getBase64Url(), rpHostname); + + // Build RelyingParty dynamically for this specific rpHostname + RelyingParty relyingParty = buildRelyingParty(rpHostname); + ResidentKeyRequirement residentKeyRequirement = ResidentKeyRequirement.PREFERRED; PublicKeyCredentialCreationOptions request = relyingParty.startRegistration(StartRegistrationOptions.builder() @@ -97,6 +126,7 @@ public AssertionStartResult startRegistration(String name, ByteArray userId) thr ); String requestId = generateRandom().getHex(); + // Cache the request options JSON which includes the rpId used webAuthnRequestCache.put(requestId, request.toJson()); return new AssertionStartResult(requestId, request.toCredentialsCreateJson()); @@ -125,6 +155,13 @@ public CredentialRegistrationResult finishRegistration(String requestId, String try { PublicKeyCredentialCreationOptions request = PublicKeyCredentialCreationOptions.fromJson(requestJson); + // Extract the rpId used during startRegistration from the cached request + String rpId = request.getRp().getId(); + log.debug("Finishing registration for request ID '{}' using RP ID '{}'", requestId, rpId); + + // Build a RelyingParty instance matching the one used at the start + RelyingParty relyingParty = buildRelyingParty(rpId); + RegistrationResult result = relyingParty.finishRegistration(FinishRegistrationOptions.builder() .request(request) .response(pkc) @@ -138,6 +175,10 @@ public CredentialRegistrationResult finishRegistration(String requestId, String } public AssertionStartResult startAssertion(String name) throws JsonProcessingException { + // For assertion, typically use the default configured Relying Party + RelyingParty relyingParty = buildDefaultRelyingParty(); + log.debug("Starting assertion for user '{}' using default RP ID '{}'", name, relyingParty.getIdentity().getId()); + AssertionRequest request = relyingParty.startAssertion(StartAssertionOptions.builder() .username(name) .build()); @@ -156,13 +197,17 @@ public CredentialAssertionResult finishAssertion(String requestId, String public } webAuthnRequestCache.invalidate(requestId); + // For assertion, assume the default Relying Party was used for startAssertion + RelyingParty relyingParty = buildDefaultRelyingParty(); + log.debug("Finishing assertion for request ID '{}' using default RP ID '{}'", requestId, relyingParty.getIdentity().getId()); + try { PublicKeyCredential pkc = PublicKeyCredential.parseAssertionResponseJson(publicKeyCredentialJson); AssertionRequest request = AssertionRequest.fromJson(requestJson); AssertionResult result = relyingParty.finishAssertion(FinishAssertionOptions.builder() - .request(request) // The PublicKeyCredentialRequestOptions from startAssertion above + .request(request) // The AssertionRequest from startAssertion above .response(pkc) .build()); @@ -188,9 +233,31 @@ public CredentialAssertionResult finishAssertion(String requestId, String public throw new CredentialAssertionFailedException(); } - private static ByteArray generateRandom() { + // Helper method to build a RelyingParty instance with a specific hostname (rpId) + private RelyingParty buildRelyingParty(String rpHostname) { + RelyingPartyIdentity rpIdentity = RelyingPartyIdentity.builder() + .id(rpHostname) + .name(relyingPartyProperties.getDisplayName()) // Use configured display name + .build(); + + return RelyingParty.builder() + .identity(rpIdentity) + .credentialRepository(databaseCredentialRepository) + .allowOriginPort(relyingPartyProperties.isAllowOriginPort()) + // Add other configurations like origins if needed, potentially from properties + // .origins(relyingPartyProperties.getOrigins()) + .build(); + } + + // Helper method to build the default RelyingParty based on configuration properties + private RelyingParty buildDefaultRelyingParty() { + return buildRelyingParty(relyingPartyProperties.getHostname()); + } + + // Made public static for potential use elsewhere, like generating IDs before calling startRegistration + public static ByteArray generateRandom() { byte[] bytes = new byte[32]; random.nextBytes(bytes); return new ByteArray(bytes); } -} \ No newline at end of file +} diff --git a/src/test/java/com/helioauth/passkeys/api/controller/CredentialsControllerTest.java b/src/test/java/com/helioauth/passkeys/api/controller/CredentialsControllerTest.java index 3cd38e7..971a469 100644 --- a/src/test/java/com/helioauth/passkeys/api/controller/CredentialsControllerTest.java +++ b/src/test/java/com/helioauth/passkeys/api/controller/CredentialsControllerTest.java @@ -68,6 +68,7 @@ class CredentialsControllerTest { .id(TEST_APP_ID) .name("test") .apiKey("testapikey") + .relyingPartyHostname("localhost") // Set a default hostname for tests .createdAt(Instant.now()) .updatedAt(Instant.now()) .build(); @@ -140,7 +141,7 @@ void postSignUpStart_existingUser() throws Exception { .header(X_APP_ID, TEST_APP_ID.toString()) .contentType("application/json") .content(requestJson) - ).andExpect(status().isOk()); + ).andExpect(status().isBadRequest()); // Expect 400 Bad Request for existing user } @Test @@ -184,4 +185,4 @@ void postSignInStart() throws Exception { .content(requestJson) ).andExpect(status().isOk()); } -} \ No newline at end of file +} diff --git a/src/test/java/com/helioauth/passkeys/api/service/UserCredentialManagerTest.java b/src/test/java/com/helioauth/passkeys/api/service/UserCredentialManagerTest.java index d582810..c4ce8bc 100644 --- a/src/test/java/com/helioauth/passkeys/api/service/UserCredentialManagerTest.java +++ b/src/test/java/com/helioauth/passkeys/api/service/UserCredentialManagerTest.java @@ -25,6 +25,7 @@ import com.helioauth.passkeys.api.generated.models.SignUpFinishRequest; import com.helioauth.passkeys.api.generated.models.SignUpFinishResponse; import com.helioauth.passkeys.api.generated.models.SignUpStartResponse; +import com.helioauth.passkeys.api.mapper.RegistrationResponseMapper; // Import mapper import com.helioauth.passkeys.api.mapper.UserCredentialMapper; import com.helioauth.passkeys.api.service.dto.AssertionStartResult; import com.helioauth.passkeys.api.service.dto.CredentialRegistrationResult; @@ -71,6 +72,9 @@ class UserCredentialManagerTest { @Spy private UserCredentialMapper userCredentialMapper = Mappers.getMapper(UserCredentialMapper.class); + @Spy // Use Spy for the mapper + private RegistrationResponseMapper registrationResponseMapper = Mappers.getMapper(RegistrationResponseMapper.class); + @InjectMocks private UserCredentialManager userCredentialManager; @@ -118,16 +122,18 @@ void getUserCredentials_returnsResult_whenResultNotEmpty() { void createCredential_returnsResponse_whenSuccessful() throws JsonProcessingException { // Arrange String userName = "testUser"; - AssertionStartResult expectedResponse = new AssertionStartResult("requestId", "{\"key\":\"value\"}"); - when(authenticator.startRegistration(userName)).thenReturn(expectedResponse); + AssertionStartResult startResult = new AssertionStartResult("requestId", "{\"key\":\"value\"}"); // Renamed variable + when(authenticator.startRegistration(userName)).thenReturn(startResult); + // No need to stub the spy mapper unless overriding specific behavior // Act SignUpStartResponse response = userCredentialManager.createCredential(userName); // Assert assertNotNull(response); - assertEquals("requestId", response.getRequestId()); - assertEquals("{\"key\":\"value\"}", response.getOptions()); + // Assert based on the real mapping logic from AssertionStartResult + assertEquals(startResult.requestId(), response.getRequestId()); + assertEquals(startResult.options(), response.getOptions()); } @Test @@ -174,4 +180,4 @@ void finishCreateCredential_throwsException_whenIOExceptionOccurs() throws IOExc // Act & Assert assertThrows(SignUpFailedException.class, () -> userCredentialManager.finishCreateCredential(finishRequest)); } -} \ No newline at end of file +} diff --git a/src/test/java/com/helioauth/passkeys/api/service/UserSignupServiceTest.java b/src/test/java/com/helioauth/passkeys/api/service/UserSignupServiceTest.java index 14a308f..e82080a 100644 --- a/src/test/java/com/helioauth/passkeys/api/service/UserSignupServiceTest.java +++ b/src/test/java/com/helioauth/passkeys/api/service/UserSignupServiceTest.java @@ -7,11 +7,13 @@ import com.helioauth.passkeys.api.domain.UserRepository; import com.helioauth.passkeys.api.generated.models.SignUpFinishResponse; import com.helioauth.passkeys.api.generated.models.SignUpStartResponse; +import com.helioauth.passkeys.api.mapper.RegistrationResponseMapper; // Added import import com.helioauth.passkeys.api.mapper.UserCredentialMapper; import com.helioauth.passkeys.api.service.dto.AssertionStartResult; import com.helioauth.passkeys.api.service.dto.CredentialRegistrationResult; import com.helioauth.passkeys.api.service.exception.SignUpFailedException; import com.helioauth.passkeys.api.service.exception.UsernameAlreadyRegisteredException; +import com.yubico.webauthn.data.ByteArray; // Added import import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mapstruct.factory.Mappers; @@ -27,8 +29,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.any; -import static org.mockito.Mockito.anyString; +import static org.mockito.ArgumentMatchers.any; // Keep this +import static org.mockito.ArgumentMatchers.anyString; // Keep this +import static org.mockito.ArgumentMatchers.eq; // Added import import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -49,6 +52,10 @@ class UserSignupServiceTest { @Spy private UserCredentialMapper userCredentialMapper = Mappers.getMapper(UserCredentialMapper.class); + // Added mock for RegistrationResponseMapper as it's used in startRegistration + @Mock + private RegistrationResponseMapper registrationResponseMapper; + @InjectMocks private UserSignupService userSignupService; @@ -56,27 +63,44 @@ class UserSignupServiceTest { void testStartRegistration_Success() throws Exception { // Arrange String name = "testuser"; - AssertionStartResult mockResponse = new AssertionStartResult("requestId123", "{\"options\":\"value\"}"); - when(webAuthnAuthenticator.startRegistration(name)).thenReturn(mockResponse); + String rpId = "test-rp.com"; // Added dummy rpId + AssertionStartResult mockAssertionResult = new AssertionStartResult("requestId123", "{\"options\":\"value\"}"); + SignUpStartResponse mockMappedResponse = new SignUpStartResponse("requestId123", "{\"options\":\"value\"}"); + + // Mock the 3-argument call made internally by the service + when(webAuthnAuthenticator.startRegistration(eq(name), any(ByteArray.class), eq(rpId))) + .thenReturn(mockAssertionResult); + // Mock the mapper call + when(registrationResponseMapper.toSignUpStartResponse(mockAssertionResult)) + .thenReturn(mockMappedResponse); + // Act - SignUpStartResponse response = userSignupService.startRegistration(name); + // Call the service method with both arguments + SignUpStartResponse response = userSignupService.startRegistration(name, rpId); // Assert assertNotNull(response); assertEquals("requestId123", response.getRequestId()); assertEquals("{\"options\":\"value\"}", response.getOptions()); - verify(webAuthnAuthenticator, times(1)).startRegistration(name); + // Verify the 3-argument internal call was made + verify(webAuthnAuthenticator, times(1)).startRegistration(eq(name), any(ByteArray.class), eq(rpId)); + verify(registrationResponseMapper, times(1)).toSignUpStartResponse(mockAssertionResult); } @Test void testStartRegistration_ThrowsSignUpFailedException() throws Exception { // Arrange String name = "testuser"; - when(webAuthnAuthenticator.startRegistration(name)).thenThrow(JsonProcessingException.class); + String rpId = "test-rp.com"; // Added dummy rpId + // Mock the 3-argument call made internally to throw the exception + when(webAuthnAuthenticator.startRegistration(eq(name), any(ByteArray.class), eq(rpId))) + .thenThrow(JsonProcessingException.class); // Act & Assert - assertThrows(SignUpFailedException.class, () -> userSignupService.startRegistration(name)); + // Call the service method with both arguments inside the lambda + assertThrows(SignUpFailedException.class, () -> userSignupService.startRegistration(name, rpId)); + verify(registrationResponseMapper, never()).toSignUpStartResponse(any()); // Mapper should not be called } @Test @@ -114,6 +138,8 @@ void testFinishRegistration_Success() throws Exception { assertNotNull(response); assertEquals(requestId, response.getRequestId()); assertNotNull(response.getUserId()); + // Verify mapper was called + verify(userCredentialMapper, times(1)).fromCredentialRegistrationResult(mockResult); } @Test @@ -138,11 +164,12 @@ void testFinishRegistration_ThrowsUsernameAlreadyRegisteredException() throws Ex } @Test - void testFinishRegistration_ThrowsSignUpFailedException() throws Exception { + void testFinishRegistration_ThrowsSignUpFailedException_OnGetUsername() throws Exception { // Arrange String requestId = "requestId123"; String publicKeyCredentialJson = "{\"key\":\"value\"}"; + // Simulate failure during getUsernameByRequestId when(webAuthnAuthenticator.getUsernameByRequestId(requestId)).thenThrow(IOException.class); // Act & Assert @@ -150,8 +177,30 @@ void testFinishRegistration_ThrowsSignUpFailedException() throws Exception { () -> userSignupService.finishRegistration(requestId, publicKeyCredentialJson) ); + verify(userRepository, never()).findByName(anyString()); // Should not proceed to check user existence verify(webAuthnAuthenticator, never()).finishRegistration(anyString(), anyString()); verify(userRepository, never()).save(any(User.class)); verify(userCredentialRepository, never()).save(any(UserCredential.class)); } -} \ No newline at end of file + + @Test + void testFinishRegistration_ThrowsSignUpFailedException_OnFinishRegistration() throws Exception { + // Arrange + String requestId = "requestId123"; + String publicKeyCredentialJson = "{\"key\":\"value\"}"; + String username = "testuser"; + + when(webAuthnAuthenticator.getUsernameByRequestId(requestId)).thenReturn(username); + when(userRepository.findByName(username)).thenReturn(Optional.empty()); + // Simulate failure during finishRegistration itself + when(webAuthnAuthenticator.finishRegistration(requestId, publicKeyCredentialJson)).thenThrow(IOException.class); + + // Act & Assert + assertThrows(SignUpFailedException.class, + () -> userSignupService.finishRegistration(requestId, publicKeyCredentialJson) + ); + + verify(userRepository, never()).save(any(User.class)); // Should not proceed to save user + verify(userCredentialRepository, never()).save(any(UserCredential.class)); // Should not proceed to save credential + } +} diff --git a/src/test/java/com/helioauth/passkeys/api/service/WebAuthnAuthenticatorTest.java b/src/test/java/com/helioauth/passkeys/api/service/WebAuthnAuthenticatorTest.java index f3ea2b8..6cdb190 100644 --- a/src/test/java/com/helioauth/passkeys/api/service/WebAuthnAuthenticatorTest.java +++ b/src/test/java/com/helioauth/passkeys/api/service/WebAuthnAuthenticatorTest.java @@ -29,9 +29,15 @@ import com.yubico.webauthn.data.ByteArray; import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions; import com.yubico.webauthn.data.RelyingPartyIdentity; +// Remove unused import: import com.yubico.webauthn.CredentialRepository; +import com.helioauth.passkeys.api.webauthn.DatabaseCredentialRepository; // Add import for the correct type +import com.helioauth.passkeys.api.mapper.RegistrationResponseMapper; // Add import for the missing spy +import com.yubico.webauthn.data.AuthenticatorAttestationResponse; // Import AuthenticatorAttestationResponse import com.yubico.webauthn.data.UserIdentity; import com.yubico.webauthn.data.exception.HexException; import com.yubico.webauthn.exception.RegistrationFailedException; +import com.helioauth.passkeys.api.config.properties.WebAuthnRelyingPartyProperties; // Import properties +import org.junit.jupiter.api.BeforeEach; // Import BeforeEach import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mapstruct.factory.Mappers; @@ -42,11 +48,16 @@ import java.io.IOException; import java.util.List; +import java.util.Set; // Import Set +import static org.junit.jupiter.api.Assertions.assertEquals; // Import assertEquals import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; // Import assertTrue import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.doReturn; // Import doReturn +import static org.mockito.Mockito.doThrow; // Import doThrow import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -57,6 +68,8 @@ public class WebAuthnAuthenticatorTest { private static final String TEST_USER_NAME = "test3"; private static final ByteArray TEST_USER_ID = new ByteArray(new byte[]{1, 2, 3}); + private static final String TEST_RP_ID = "localhost"; // Add RP constants + private static final String TEST_RP_NAME = "Test RP"; private static final UserIdentity USER_IDENTITY = UserIdentity.builder() .name(TEST_USER_NAME) .displayName(TEST_USER_NAME) @@ -98,89 +111,91 @@ public class WebAuthnAuthenticatorTest { "clientExtensionResults": { "credProps": { "rk": true } } }"""; - @Mock - private RelyingParty relyingParty; - @Mock private Cache webAuthnRequestCache; @Spy + private WebAuthnRelyingPartyProperties relyingPartyProperties = new WebAuthnRelyingPartyProperties(); + + @Mock + private DatabaseCredentialRepository databaseCredentialRepository; + + @Spy // Keep Spy for the mapper private CredentialRegistrationResultMapper credentialRegistrationResultMapper = Mappers.getMapper(CredentialRegistrationResultMapper.class); + // Add the missing spy + @Spy + private RegistrationResponseMapper registrationResponseMapper = Mappers.getMapper(RegistrationResponseMapper.class); + @InjectMocks private WebAuthnAuthenticator authenticator; - - public void setUpRelyingPartyIdentityMock() { - when(relyingParty.getIdentity()).thenReturn( - RelyingPartyIdentity.builder().id("testId").name("testName").build() - ); + + // Remove setUpRelyingPartyIdentityMock method + + @BeforeEach + void setUp() { + relyingPartyProperties.setHostname(TEST_RP_ID); + relyingPartyProperties.setDisplayName(TEST_RP_NAME); + relyingPartyProperties.setAllowOriginPort(true); } @Test public void testStartRegistrationWithNameAndUserId() throws JsonProcessingException, HexException { - setUpRelyingPartyIdentityMock(); - - PublicKeyCredentialCreationOptions pkcco = PublicKeyCredentialCreationOptions.builder() - .rp(relyingParty.getIdentity()) - .user(USER_IDENTITY) - .challenge(ByteArray.fromHex("1234567890abcdef")) - .pubKeyCredParams(List.of()) - .build(); - - when(relyingParty.startRegistration(any(StartRegistrationOptions.class))).thenReturn(pkcco); + // RelyingParty is built inside authenticator.startRegistration // Execute the test - AssertionStartResult response = authenticator.startRegistration(TEST_USER_NAME, TEST_USER_ID); + // Use the 3-arg version for clarity, passing the mocked RP ID + AssertionStartResult response = authenticator.startRegistration(TEST_USER_NAME, TEST_USER_ID, TEST_RP_ID); // Verify interactions and assert results - verify(relyingParty, times(1)).startRegistration(any(StartRegistrationOptions.class)); verify(webAuthnRequestCache, times(1)).put(anyString(), anyString()); assertNotNull(response); assertNotNull(response.requestId()); assertNotNull(response.options()); + // Optionally, assert specific options based on the mocked properties + assertTrue(response.options().contains("\"id\":\"" + TEST_RP_ID + "\"")); + assertTrue(response.options().contains("\"name\":\"" + TEST_RP_NAME + "\"")); } @Test public void testStartRegistrationWithName() throws JsonProcessingException, HexException { - setUpRelyingPartyIdentityMock(); - - PublicKeyCredentialCreationOptions pkcco = PublicKeyCredentialCreationOptions.builder() - .rp(relyingParty.getIdentity()) - .user(USER_IDENTITY) - .challenge(ByteArray.fromHex("1234567890abcdef")) - .pubKeyCredParams(List.of()) - .build(); - - when(relyingParty.startRegistration(any(StartRegistrationOptions.class))).thenReturn(pkcco); + // RelyingParty is built inside authenticator.startRegistration using default hostname from properties // Execute the test -// SignUpStartResponse response = authenticator.startRegistration(TEST_USER_NAME); AssertionStartResult response = authenticator.startRegistration(TEST_USER_NAME); // Verify interactions and assert results - verify(relyingParty, times(1)).startRegistration(any(StartRegistrationOptions.class)); verify(webAuthnRequestCache, times(1)).put(anyString(), anyString()); assertNotNull(response); assertNotNull(response.requestId()); assertNotNull(response.options()); + assertTrue(response.options().contains("\"id\":\"" + TEST_RP_ID + "\"")); + assertTrue(response.options().contains("\"name\":\"" + TEST_RP_NAME + "\"")); } @Test public void testFinishRegistrationSuccess() throws IOException, RegistrationFailedException { String requestId = "requestId"; - RegistrationResult mockResult = mock( RegistrationResult.class); + // Prepare the expected result from the mapper + CredentialRegistrationResult mappedResult = new CredentialRegistrationResult("name", "displayName", "credentialId", "userHandle", 1L, "publicKeyCose", + "attestationObject", "clientDataJson", true, true, true); + + // Stub the spy's method using doReturn/when syntax + doReturn(mappedResult).when(credentialRegistrationResultMapper) + .fromRegistrationResult( + any(RegistrationResult.class), + any(UserIdentity.class), + any(AuthenticatorAttestationResponse.class) // Use specific type + ); when(webAuthnRequestCache.getIfPresent(requestId)).thenReturn(AUTHENTICATOR_REQUEST_JSON); - when(relyingParty.finishRegistration(any(FinishRegistrationOptions.class))).thenReturn(mockResult); - when(credentialRegistrationResultMapper.fromRegistrationResult(any(), any(), any())) - .thenReturn(new CredentialRegistrationResult("name", "displayName", "credentialId", "userHandle", 1L, "publicKeyCose", - "attestationObject", "clientDataJson", true, true, true)); + // Internal call to PublicKeyCredential.parseRegistrationResponseJson happens CredentialRegistrationResult result = authenticator.finishRegistration(requestId, AUTHENTICATOR_RESPONSE_JSON); assertNotNull(result); - assertNotNull(result.name()); + assertEquals(mappedResult.name(), result.name()); // Check mapped result (which came from the stub) verify(webAuthnRequestCache, times(1)).invalidate(requestId); } @@ -202,17 +217,26 @@ public void testFinishRegistrationRequestIdNotFound() { @Test public void testFinishRegistrationThrowsException() throws RegistrationFailedException { String requestId = "requestId"; - when(webAuthnRequestCache.getIfPresent(requestId)).thenReturn(AUTHENTICATOR_REQUEST_JSON); - when(relyingParty.finishRegistration(any(FinishRegistrationOptions.class))) - .thenThrow(new RegistrationFailedException(new IllegalArgumentException())); + // Simulate failure during mapping using doThrow/when syntax for the spy + doThrow(new RuntimeException("Simulated mapping error")).when(credentialRegistrationResultMapper) + .fromRegistrationResult( + any(RegistrationResult.class), + any(UserIdentity.class), + any(AuthenticatorAttestationResponse.class) // Use specific type + ); + + // Assert that our service wraps the internal exception CredentialRegistrationFailedException exception = assertThrows( CredentialRegistrationFailedException.class, () -> authenticator.finishRegistration(requestId, AUTHENTICATOR_RESPONSE_JSON) ); assertNotNull(exception.getMessage()); - + assertTrue(exception.getMessage().contains("Failed to finish registration")); // Check message + assertNotNull(exception.getCause()); // Check that the original cause is wrapped + assertEquals("Simulated mapping error", exception.getCause().getMessage()); + verify(webAuthnRequestCache, times(1)).invalidate(requestId); } -} \ No newline at end of file +} From 6b15a7f2957224bc30d5304c3bea480d127f5dfa Mon Sep 17 00:00:00 2001 From: Victor Stanchev Date: Fri, 18 Apr 2025 07:34:47 +0300 Subject: [PATCH 05/21] Get client application from security context --- .../api/auth/ApplicationApiKeyAuthenticationProvider.java | 2 +- .../api/auth/ApplicationIdAuthenticationProvider.java | 2 +- .../passkeys/api/controller/CredentialsController.java | 7 ++++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/helioauth/passkeys/api/auth/ApplicationApiKeyAuthenticationProvider.java b/src/main/java/com/helioauth/passkeys/api/auth/ApplicationApiKeyAuthenticationProvider.java index 90ce743..7b8a7b4 100644 --- a/src/main/java/com/helioauth/passkeys/api/auth/ApplicationApiKeyAuthenticationProvider.java +++ b/src/main/java/com/helioauth/passkeys/api/auth/ApplicationApiKeyAuthenticationProvider.java @@ -47,8 +47,8 @@ public Authentication authenticate(Authentication authentication) throws Authent .orElseThrow(() -> new BadCredentialsException("Invalid api key")); PreAuthenticatedAuthenticationToken authenticatedToken = new PreAuthenticatedAuthenticationToken( - clientApp.getId(), clientApp, + clientApp.getApiKey(), List.of(new SimpleGrantedAuthority("ROLE_APPLICATION")) ); authenticatedToken.setDetails(clientApp); diff --git a/src/main/java/com/helioauth/passkeys/api/auth/ApplicationIdAuthenticationProvider.java b/src/main/java/com/helioauth/passkeys/api/auth/ApplicationIdAuthenticationProvider.java index 0b595b6..402e812 100644 --- a/src/main/java/com/helioauth/passkeys/api/auth/ApplicationIdAuthenticationProvider.java +++ b/src/main/java/com/helioauth/passkeys/api/auth/ApplicationIdAuthenticationProvider.java @@ -50,8 +50,8 @@ public Authentication authenticate(Authentication authentication) throws Authent .orElseThrow(() -> new BadCredentialsException("Invalid application ID")); PreAuthenticatedAuthenticationToken authenticatedToken = new PreAuthenticatedAuthenticationToken( - clientApp.getId(), clientApp, + clientApp.getId(), List.of(new SimpleGrantedAuthority("ROLE_FRONTEND_APPLICATION")) ); authenticatedToken.setDetails(clientApp); diff --git a/src/main/java/com/helioauth/passkeys/api/controller/CredentialsController.java b/src/main/java/com/helioauth/passkeys/api/controller/CredentialsController.java index 80c1f49..7186c51 100644 --- a/src/main/java/com/helioauth/passkeys/api/controller/CredentialsController.java +++ b/src/main/java/com/helioauth/passkeys/api/controller/CredentialsController.java @@ -36,6 +36,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; @@ -55,12 +56,12 @@ public class CredentialsController implements SignUpApi, SignInApi { private final UserSignInService userSignInService; private final UserSignupService userSignupService; - public ResponseEntity postSignupStart(@RequestBody @Valid SignUpStartRequest request, Authentication authentication) { - if (authentication == null || !(authentication.getPrincipal() instanceof ClientApplication)) { + public ResponseEntity postSignupStart(@RequestBody @Valid SignUpStartRequest request) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !(authentication.getPrincipal() instanceof ClientApplication clientApp)) { log.error("Signup start request received without valid ClientApplication authentication."); throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Client application not authenticated"); } - ClientApplication clientApp = (ClientApplication) authentication.getPrincipal(); String rpId = clientApp.getRelyingPartyHostname(); From 74603802fa5443d9debd7702676e41c307ee085d Mon Sep 17 00:00:00 2001 From: Victor Stanchev Date: Fri, 18 Apr 2025 08:05:38 +0300 Subject: [PATCH 06/21] Read origins from application for CORS --- .../api/config/WebSecurityConfig.java | 36 +++++++++++++++++-- .../api/controller/CredentialsController.java | 2 -- .../api/domain/ClientApplication.java | 3 ++ 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/helioauth/passkeys/api/config/WebSecurityConfig.java b/src/main/java/com/helioauth/passkeys/api/config/WebSecurityConfig.java index 94cce3b..a1048b6 100644 --- a/src/main/java/com/helioauth/passkeys/api/config/WebSecurityConfig.java +++ b/src/main/java/com/helioauth/passkeys/api/config/WebSecurityConfig.java @@ -26,7 +26,6 @@ import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.ProviderManager; -import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; @@ -38,6 +37,12 @@ import jakarta.servlet.http.HttpServletResponse; import java.util.List; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import com.helioauth.passkeys.api.domain.ClientApplication; + /** * @author Viktor Stanchev @@ -57,7 +62,7 @@ public SecurityFilterChain filterChain(HttpSecurity http, RequestHeaderAuthenticationFilter applicationApiKeyAuthFilter) throws Exception { http - .cors(Customizer.withDefaults()) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) .csrf(AbstractHttpConfigurer::disable) .sessionManagement(config -> config.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .addFilterAfter(adminAuthFilter, HeaderWriterFilter.class) @@ -120,4 +125,31 @@ protected AuthenticationManager appIdAuthenticationManager() { protected AuthenticationManager appApiKeyAuthenticationManager() { return new ProviderManager(List.of(applicationApiKeyAuthenticationProvider)); } + + @Bean + CorsConfigurationSource corsConfigurationSource() { + return _ -> { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !(authentication.getPrincipal() instanceof ClientApplication)) { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.applyPermitDefaultValues(); + return configuration; + } + + ClientApplication clientApplication = (ClientApplication) authentication.getPrincipal(); + String allowedOrigins = clientApplication.getAllowedOrigins(); + + CorsConfiguration configuration = new CorsConfiguration(); + if (allowedOrigins != null && !allowedOrigins.isEmpty()) { + configuration.setAllowedOrigins(List.of(allowedOrigins.split(","))); + configuration.setAllowedMethods(List.of("*")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowCredentials(true); + } else { + configuration.applyPermitDefaultValues(); + } + + return configuration; + }; + } } diff --git a/src/main/java/com/helioauth/passkeys/api/controller/CredentialsController.java b/src/main/java/com/helioauth/passkeys/api/controller/CredentialsController.java index 7186c51..e079cbe 100644 --- a/src/main/java/com/helioauth/passkeys/api/controller/CredentialsController.java +++ b/src/main/java/com/helioauth/passkeys/api/controller/CredentialsController.java @@ -37,7 +37,6 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; @@ -49,7 +48,6 @@ */ @Slf4j @RestController -@CrossOrigin(origins = "*") @RequiredArgsConstructor public class CredentialsController implements SignUpApi, SignInApi { diff --git a/src/main/java/com/helioauth/passkeys/api/domain/ClientApplication.java b/src/main/java/com/helioauth/passkeys/api/domain/ClientApplication.java index 9b23357..cc650d0 100644 --- a/src/main/java/com/helioauth/passkeys/api/domain/ClientApplication.java +++ b/src/main/java/com/helioauth/passkeys/api/domain/ClientApplication.java @@ -86,4 +86,7 @@ public ClientApplication(String name, String apiKey) { this.name = name; this.apiKey = apiKey; } + + @Column(name = "allowed_origins", nullable = true) + private String allowedOrigins; } \ No newline at end of file From 1a0f02ecd2455c6193546a81eb7986dc7803ed35 Mon Sep 17 00:00:00 2001 From: Victor Stanchev Date: Fri, 18 Apr 2025 08:10:59 +0300 Subject: [PATCH 07/21] Fixed test --- .../api/service/WebAuthnAuthenticator.java | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/helioauth/passkeys/api/service/WebAuthnAuthenticator.java b/src/main/java/com/helioauth/passkeys/api/service/WebAuthnAuthenticator.java index f633861..cb43816 100644 --- a/src/main/java/com/helioauth/passkeys/api/service/WebAuthnAuthenticator.java +++ b/src/main/java/com/helioauth/passkeys/api/service/WebAuthnAuthenticator.java @@ -152,16 +152,16 @@ public CredentialRegistrationResult finishRegistration(String requestId, String PublicKeyCredential pkc = PublicKeyCredential.parseRegistrationResponseJson(publicKeyCredentialJson); - try { - PublicKeyCredentialCreationOptions request = PublicKeyCredentialCreationOptions.fromJson(requestJson); + PublicKeyCredentialCreationOptions request = PublicKeyCredentialCreationOptions.fromJson(requestJson); - // Extract the rpId used during startRegistration from the cached request - String rpId = request.getRp().getId(); - log.debug("Finishing registration for request ID '{}' using RP ID '{}'", requestId, rpId); + // Extract the rpId used during startRegistration from the cached request + String rpId = request.getRp().getId(); + log.debug("Finishing registration for request ID '{}' using RP ID '{}'", requestId, rpId); - // Build a RelyingParty instance matching the one used at the start - RelyingParty relyingParty = buildRelyingParty(rpId); + // Build a RelyingParty instance matching the one used at the start + RelyingParty relyingParty = buildRelyingParty(rpId); + try { RegistrationResult result = relyingParty.finishRegistration(FinishRegistrationOptions.builder() .request(request) .response(pkc) @@ -169,8 +169,8 @@ public CredentialRegistrationResult finishRegistration(String requestId, String return credentialRegistrationResultMapper.fromRegistrationResult(result, request.getUser(), pkc.getResponse()); - } catch (RegistrationFailedException e) { - throw new CredentialRegistrationFailedException(e.getMessage(), e); + } catch (Exception e) { + throw new CredentialRegistrationFailedException("Failed to finish registration", e); } } From 0d58ad091ce4d7789013dbb87c2e5305c253095d Mon Sep 17 00:00:00 2001 From: Victor Stanchev Date: Fri, 18 Apr 2025 10:06:58 +0300 Subject: [PATCH 08/21] Added missing fields in application --- docs/openapi/components/schemas/Application.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/openapi/components/schemas/Application.yaml b/docs/openapi/components/schemas/Application.yaml index 7ff60ce..9e80e83 100644 --- a/docs/openapi/components/schemas/Application.yaml +++ b/docs/openapi/components/schemas/Application.yaml @@ -16,3 +16,9 @@ properties: type: string format: date-time description: Timestamp when the application was last updated. + relyingPartyHostname: + type: string + description: Hostname of the relying party. + allowedOrigins: + type: string + description: Allowed origins for the application. From e5b04925d2c415861e26b0cdb659f0d8438419f1 Mon Sep 17 00:00:00 2001 From: Victor Stanchev Date: Fri, 18 Apr 2025 10:08:11 +0300 Subject: [PATCH 09/21] feat: Added hostname and origins to add and edit endpoints --- .../schemas/AddApplicationRequest.yaml | 8 ++++++- .../schemas/EditApplicationRequest.yaml | 12 ++++++++++ docs/openapi/paths/admin_v1_apps_id.yaml | 2 +- .../ClientApplicationController.java | 13 ++++++---- .../api/service/ClientApplicationService.java | 24 ++++++++++++------- 5 files changed, 44 insertions(+), 15 deletions(-) create mode 100644 docs/openapi/components/schemas/EditApplicationRequest.yaml diff --git a/docs/openapi/components/schemas/AddApplicationRequest.yaml b/docs/openapi/components/schemas/AddApplicationRequest.yaml index f7a2ef2..d6d724a 100644 --- a/docs/openapi/components/schemas/AddApplicationRequest.yaml +++ b/docs/openapi/components/schemas/AddApplicationRequest.yaml @@ -1,6 +1,12 @@ type: object -description: Request to add a new client application. Contains the name of the application. +description: Request to add a new client application properties: name: type: string description: Name of the new application. + relyingPartyHostname: + type: string + description: Hostname of the application, e.g. example.com + allowedOrigins: + type: string + description: Allowed origins for the application, comma separated \ No newline at end of file diff --git a/docs/openapi/components/schemas/EditApplicationRequest.yaml b/docs/openapi/components/schemas/EditApplicationRequest.yaml new file mode 100644 index 0000000..0ef183f --- /dev/null +++ b/docs/openapi/components/schemas/EditApplicationRequest.yaml @@ -0,0 +1,12 @@ +type: object +description: Represents a request to edit an application. +properties: + name: + type: string + description: Name of the application. + relyingPartyHostname: + type: string + description: Hostname of the relying party. + allowedOrigins: + type: string + description: Allowed origins for the application, comma separated \ No newline at end of file diff --git a/docs/openapi/paths/admin_v1_apps_id.yaml b/docs/openapi/paths/admin_v1_apps_id.yaml index 4f9c115..4bbc7e5 100644 --- a/docs/openapi/paths/admin_v1_apps_id.yaml +++ b/docs/openapi/paths/admin_v1_apps_id.yaml @@ -41,7 +41,7 @@ put: content: application/json: schema: - type: string + $ref: ../components/schemas/EditApplicationRequest.yaml required: true responses: '200': diff --git a/src/main/java/com/helioauth/passkeys/api/controller/ClientApplicationController.java b/src/main/java/com/helioauth/passkeys/api/controller/ClientApplicationController.java index 41565bc..b764ff1 100644 --- a/src/main/java/com/helioauth/passkeys/api/controller/ClientApplicationController.java +++ b/src/main/java/com/helioauth/passkeys/api/controller/ClientApplicationController.java @@ -20,7 +20,10 @@ import com.helioauth.passkeys.api.generated.models.AddApplicationRequest; import com.helioauth.passkeys.api.generated.models.Application; import com.helioauth.passkeys.api.generated.models.ApplicationApiKey; +import com.helioauth.passkeys.api.generated.models.EditApplicationRequest; import com.helioauth.passkeys.api.service.ClientApplicationService; + +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.val; import org.springframework.http.ResponseEntity; @@ -39,9 +42,9 @@ @RequiredArgsConstructor public class ClientApplicationController implements ApplicationsApi { - private final ClientApplicationService clientApplicationService; + private final ClientApplicationService clientApplicationService; - public ResponseEntity> listAll() { + public ResponseEntity> listAll() { return ResponseEntity.ok(clientApplicationService.listAll()); } @@ -58,14 +61,14 @@ public ResponseEntity getApiKey(@PathVariable UUID id) { } public ResponseEntity add(@RequestBody AddApplicationRequest request) { - Application created = clientApplicationService.add(request.getName()); + Application created = clientApplicationService.add(request); return ResponseEntity.created(URI.create("/admin/v1/apps/" + created.getId())) .body(created); } - public ResponseEntity edit(@PathVariable UUID id, @RequestBody String name) { - val updated = clientApplicationService.edit(id, name); + public ResponseEntity edit(@PathVariable UUID id, @RequestBody @Valid EditApplicationRequest request) { + val updated = clientApplicationService.edit(id, request); return updated .map(ResponseEntity::ok) diff --git a/src/main/java/com/helioauth/passkeys/api/service/ClientApplicationService.java b/src/main/java/com/helioauth/passkeys/api/service/ClientApplicationService.java index 1c33b33..70c22e2 100644 --- a/src/main/java/com/helioauth/passkeys/api/service/ClientApplicationService.java +++ b/src/main/java/com/helioauth/passkeys/api/service/ClientApplicationService.java @@ -18,8 +18,10 @@ import com.helioauth.passkeys.api.domain.ClientApplication; import com.helioauth.passkeys.api.domain.ClientApplicationRepository; +import com.helioauth.passkeys.api.generated.models.AddApplicationRequest; import com.helioauth.passkeys.api.generated.models.Application; import com.helioauth.passkeys.api.generated.models.ApplicationApiKey; +import com.helioauth.passkeys.api.generated.models.EditApplicationRequest; import com.helioauth.passkeys.api.mapper.ClientApplicationMapper; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -59,22 +61,28 @@ public List listAll() { ); } - public Application add(String name) { - + public Application add(AddApplicationRequest request) { return clientApplicationMapper.toResponse( repository.save( - new ClientApplication(name, generateApiKey()) + ClientApplication.builder() + .name(request.getName()) + .apiKey(generateApiKey()) + .relyingPartyHostname(request.getRelyingPartyHostname()) + .allowedOrigins(request.getAllowedOrigins()) + .build() ) ); } @Transactional - public Optional edit(UUID id, String name) { + public Optional edit(UUID id, EditApplicationRequest request) { return repository.findById(id) - .map(existing -> { - existing.setName(name); - return clientApplicationMapper.toResponse(repository.save(existing)); - }); + .map(existing -> { + existing.setName(request.getName()); + existing.setRelyingPartyHostname(request.getRelyingPartyHostname()); + existing.setAllowedOrigins(request.getAllowedOrigins()); + return clientApplicationMapper.toResponse(repository.save(existing)); + }); } @Transactional From 81e81235761dd04235e4210653d7ded4f2ef4f79 Mon Sep 17 00:00:00 2001 From: Victor Stanchev Date: Fri, 18 Apr 2025 10:09:02 +0300 Subject: [PATCH 10/21] test: Updated application service test --- .../api/service/ClientApplicationServiceTest.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/helioauth/passkeys/api/service/ClientApplicationServiceTest.java b/src/test/java/com/helioauth/passkeys/api/service/ClientApplicationServiceTest.java index d22336b..e36f104 100644 --- a/src/test/java/com/helioauth/passkeys/api/service/ClientApplicationServiceTest.java +++ b/src/test/java/com/helioauth/passkeys/api/service/ClientApplicationServiceTest.java @@ -18,8 +18,10 @@ import com.helioauth.passkeys.api.domain.ClientApplication; import com.helioauth.passkeys.api.domain.ClientApplicationRepository; +import com.helioauth.passkeys.api.generated.models.AddApplicationRequest; import com.helioauth.passkeys.api.generated.models.Application; import com.helioauth.passkeys.api.generated.models.ApplicationApiKey; +import com.helioauth.passkeys.api.generated.models.EditApplicationRequest; import com.helioauth.passkeys.api.mapper.ClientApplicationMapper; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -75,7 +77,9 @@ public void addClientApplicationTest() { when(repository.save(any(ClientApplication.class))).thenReturn(CLIENT_APPLICATION); // Execute - Application result = service.add(DTO.getName()); + AddApplicationRequest addApplicationRequest = new AddApplicationRequest(); + addApplicationRequest.setName(DTO.getName()); + Application result = service.add(addApplicationRequest); // Capture the argument verify(repository).save(argumentCaptor.capture()); @@ -119,7 +123,9 @@ public void editClientApplicationTest() { when(repository.save(any(ClientApplication.class))).thenReturn(existingClientApplication); // Execute - Optional result = service.edit(id, newName); + EditApplicationRequest editApplicationRequest = new EditApplicationRequest(); + editApplicationRequest.setName(newName); + Optional result = service.edit(id, editApplicationRequest); // Validate assertTrue(result.isPresent()); From bcfaebd81690b3e7b7c129e0ad1a877936255f7f Mon Sep 17 00:00:00 2001 From: Victor Stanchev Date: Fri, 18 Apr 2025 10:09:24 +0300 Subject: [PATCH 11/21] chore: Reordered ClientApplication properties --- .../helioauth/passkeys/api/domain/ClientApplication.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/helioauth/passkeys/api/domain/ClientApplication.java b/src/main/java/com/helioauth/passkeys/api/domain/ClientApplication.java index cc650d0..f71d736 100644 --- a/src/main/java/com/helioauth/passkeys/api/domain/ClientApplication.java +++ b/src/main/java/com/helioauth/passkeys/api/domain/ClientApplication.java @@ -69,6 +69,9 @@ public class ClientApplication { @Column(name = "relying_party_hostname", nullable = true) private String relyingPartyHostname; + @Column(name = "allowed_origins", nullable = true) + private String allowedOrigins; + @CreatedDate @Temporal(TemporalType.TIMESTAMP) @Column(name = "created_at", nullable = false) @@ -86,7 +89,4 @@ public ClientApplication(String name, String apiKey) { this.name = name; this.apiKey = apiKey; } - - @Column(name = "allowed_origins", nullable = true) - private String allowedOrigins; } \ No newline at end of file From 6c92c969e972804d37f69a2c8460e63c43b4ec65 Mon Sep 17 00:00:00 2001 From: Victor Stanchev Date: Fri, 18 Apr 2025 10:29:54 +0300 Subject: [PATCH 12/21] chore: Updated gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 549e00a..30e4e2d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ HELP.md +TODO.md target/ !.mvn/wrapper/maven-wrapper.jar !**/src/main/**/target/ @@ -31,3 +32,4 @@ build/ ### VS Code ### .vscode/ +.aider* From 96438f123dc93003f65f5a899c3f21b31bb47f7c Mon Sep 17 00:00:00 2001 From: Victor Stanchev Date: Fri, 18 Apr 2025 10:44:17 +0300 Subject: [PATCH 13/21] chore: Removed excess comments --- .../api/service/UserSignupService.java | 3 - .../api/service/WebAuthnAuthenticator.java | 33 +---------- .../service/UserCredentialManagerTest.java | 5 +- .../api/service/UserSignupServiceTest.java | 32 ++++------- .../service/WebAuthnAuthenticatorTest.java | 55 +++++-------------- 5 files changed, 29 insertions(+), 99 deletions(-) diff --git a/src/main/java/com/helioauth/passkeys/api/service/UserSignupService.java b/src/main/java/com/helioauth/passkeys/api/service/UserSignupService.java index 802ea0b..deec02a 100644 --- a/src/main/java/com/helioauth/passkeys/api/service/UserSignupService.java +++ b/src/main/java/com/helioauth/passkeys/api/service/UserSignupService.java @@ -28,7 +28,6 @@ import com.helioauth.passkeys.api.service.dto.CredentialRegistrationResult; import com.helioauth.passkeys.api.service.exception.SignUpFailedException; import com.helioauth.passkeys.api.service.exception.UsernameAlreadyRegisteredException; -// Import ByteArray if needed, or rely on WebAuthnAuthenticator.generateRandom() import com.yubico.webauthn.data.ByteArray; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -52,14 +51,12 @@ public class UserSignupService { private final RegistrationResponseMapper registrationResponseMapper; public SignUpStartResponse startRegistration(String name, String rpId) { - // Check if a user with this name already exists if (userRepository.findByName(name).isPresent()) { log.warn("Attempted to start registration for already existing username: {}", name); throw new UsernameAlreadyRegisteredException(); } try { - // If user does not exist, proceed with generating ID and starting registration ByteArray userId = WebAuthnAuthenticator.generateRandom(); return registrationResponseMapper.toSignUpStartResponse( webAuthnAuthenticator.startRegistration(name, userId, rpId) diff --git a/src/main/java/com/helioauth/passkeys/api/service/WebAuthnAuthenticator.java b/src/main/java/com/helioauth/passkeys/api/service/WebAuthnAuthenticator.java index cb43816..72df234 100644 --- a/src/main/java/com/helioauth/passkeys/api/service/WebAuthnAuthenticator.java +++ b/src/main/java/com/helioauth/passkeys/api/service/WebAuthnAuthenticator.java @@ -65,46 +65,27 @@ @RequiredArgsConstructor public class WebAuthnAuthenticator { - // Removed: private final RelyingParty relyingParty; - - // Added dependencies needed to build RelyingParty instances private final DatabaseCredentialRepository databaseCredentialRepository; private final WebAuthnRelyingPartyProperties relyingPartyProperties; private final CredentialRegistrationResultMapper credentialRegistrationResultMapper; private final Cache webAuthnRequestCache; - private static final SecureRandom random = new SecureRandom(); private final RegistrationResponseMapper registrationResponseMapper; public AssertionStartResult startRegistration(String name) throws JsonProcessingException { ByteArray id = generateRandom(); - // This existing method now implicitly calls the new 3-arg method via the 2-arg one below return startRegistration(name, id); } public AssertionStartResult startRegistration(String name, ByteArray userId) throws JsonProcessingException { - // Delegate to the new 3-argument method, using the configured default RP ID from properties return startRegistration(name, userId, relyingPartyProperties.getHostname()); } - /** - * Start registration for a given user and RP Hostname. - * Note: Currently, the rpHostname parameter is not used to dynamically change the RelyingParty, - * as the relyingParty bean is configured globally. This parameter is added for potential future use - * (e.g., associating the request with a specific RP context). - * - * @param name The username. - * @param userId The user's unique ID. - * @param rpHostname The hostname of the relying party (currently informational). - * @return AssertionStartResult containing the request ID and creation options. - * @throws JsonProcessingException If JSON processing fails. - */ public AssertionStartResult startRegistration(String name, ByteArray userId, String rpHostname) throws JsonProcessingException { log.debug("Starting registration for user '{}' with id '{}' for RP '{}'", name, userId.getBase64Url(), rpHostname); - // Build RelyingParty dynamically for this specific rpHostname RelyingParty relyingParty = buildRelyingParty(rpHostname); ResidentKeyRequirement residentKeyRequirement = ResidentKeyRequirement.PREFERRED; @@ -126,7 +107,6 @@ public AssertionStartResult startRegistration(String name, ByteArray userId, Str ); String requestId = generateRandom().getHex(); - // Cache the request options JSON which includes the rpId used webAuthnRequestCache.put(requestId, request.toJson()); return new AssertionStartResult(requestId, request.toCredentialsCreateJson()); @@ -154,11 +134,9 @@ public CredentialRegistrationResult finishRegistration(String requestId, String PublicKeyCredentialCreationOptions request = PublicKeyCredentialCreationOptions.fromJson(requestJson); - // Extract the rpId used during startRegistration from the cached request String rpId = request.getRp().getId(); log.debug("Finishing registration for request ID '{}' using RP ID '{}'", requestId, rpId); - // Build a RelyingParty instance matching the one used at the start RelyingParty relyingParty = buildRelyingParty(rpId); try { @@ -175,7 +153,6 @@ public CredentialRegistrationResult finishRegistration(String requestId, String } public AssertionStartResult startAssertion(String name) throws JsonProcessingException { - // For assertion, typically use the default configured Relying Party RelyingParty relyingParty = buildDefaultRelyingParty(); log.debug("Starting assertion for user '{}' using default RP ID '{}'", name, relyingParty.getIdentity().getId()); @@ -197,7 +174,6 @@ public CredentialAssertionResult finishAssertion(String requestId, String public } webAuthnRequestCache.invalidate(requestId); - // For assertion, assume the default Relying Party was used for startAssertion RelyingParty relyingParty = buildDefaultRelyingParty(); log.debug("Finishing assertion for request ID '{}' using default RP ID '{}'", requestId, relyingParty.getIdentity().getId()); @@ -207,7 +183,7 @@ public CredentialAssertionResult finishAssertion(String requestId, String public AssertionRequest request = AssertionRequest.fromJson(requestJson); AssertionResult result = relyingParty.finishAssertion(FinishAssertionOptions.builder() - .request(request) // The AssertionRequest from startAssertion above + .request(request) .response(pkc) .build()); @@ -233,28 +209,23 @@ public CredentialAssertionResult finishAssertion(String requestId, String public throw new CredentialAssertionFailedException(); } - // Helper method to build a RelyingParty instance with a specific hostname (rpId) private RelyingParty buildRelyingParty(String rpHostname) { RelyingPartyIdentity rpIdentity = RelyingPartyIdentity.builder() .id(rpHostname) - .name(relyingPartyProperties.getDisplayName()) // Use configured display name + .name(relyingPartyProperties.getDisplayName()) .build(); return RelyingParty.builder() .identity(rpIdentity) .credentialRepository(databaseCredentialRepository) .allowOriginPort(relyingPartyProperties.isAllowOriginPort()) - // Add other configurations like origins if needed, potentially from properties - // .origins(relyingPartyProperties.getOrigins()) .build(); } - // Helper method to build the default RelyingParty based on configuration properties private RelyingParty buildDefaultRelyingParty() { return buildRelyingParty(relyingPartyProperties.getHostname()); } - // Made public static for potential use elsewhere, like generating IDs before calling startRegistration public static ByteArray generateRandom() { byte[] bytes = new byte[32]; random.nextBytes(bytes); diff --git a/src/test/java/com/helioauth/passkeys/api/service/UserCredentialManagerTest.java b/src/test/java/com/helioauth/passkeys/api/service/UserCredentialManagerTest.java index c4ce8bc..676bc9c 100644 --- a/src/test/java/com/helioauth/passkeys/api/service/UserCredentialManagerTest.java +++ b/src/test/java/com/helioauth/passkeys/api/service/UserCredentialManagerTest.java @@ -25,7 +25,7 @@ import com.helioauth.passkeys.api.generated.models.SignUpFinishRequest; import com.helioauth.passkeys.api.generated.models.SignUpFinishResponse; import com.helioauth.passkeys.api.generated.models.SignUpStartResponse; -import com.helioauth.passkeys.api.mapper.RegistrationResponseMapper; // Import mapper +import com.helioauth.passkeys.api.mapper.RegistrationResponseMapper; import com.helioauth.passkeys.api.mapper.UserCredentialMapper; import com.helioauth.passkeys.api.service.dto.AssertionStartResult; import com.helioauth.passkeys.api.service.dto.CredentialRegistrationResult; @@ -72,7 +72,7 @@ class UserCredentialManagerTest { @Spy private UserCredentialMapper userCredentialMapper = Mappers.getMapper(UserCredentialMapper.class); - @Spy // Use Spy for the mapper + @Spy private RegistrationResponseMapper registrationResponseMapper = Mappers.getMapper(RegistrationResponseMapper.class); @InjectMocks @@ -124,7 +124,6 @@ void createCredential_returnsResponse_whenSuccessful() throws JsonProcessingExce String userName = "testUser"; AssertionStartResult startResult = new AssertionStartResult("requestId", "{\"key\":\"value\"}"); // Renamed variable when(authenticator.startRegistration(userName)).thenReturn(startResult); - // No need to stub the spy mapper unless overriding specific behavior // Act SignUpStartResponse response = userCredentialManager.createCredential(userName); diff --git a/src/test/java/com/helioauth/passkeys/api/service/UserSignupServiceTest.java b/src/test/java/com/helioauth/passkeys/api/service/UserSignupServiceTest.java index e82080a..2c49c29 100644 --- a/src/test/java/com/helioauth/passkeys/api/service/UserSignupServiceTest.java +++ b/src/test/java/com/helioauth/passkeys/api/service/UserSignupServiceTest.java @@ -7,13 +7,13 @@ import com.helioauth.passkeys.api.domain.UserRepository; import com.helioauth.passkeys.api.generated.models.SignUpFinishResponse; import com.helioauth.passkeys.api.generated.models.SignUpStartResponse; -import com.helioauth.passkeys.api.mapper.RegistrationResponseMapper; // Added import +import com.helioauth.passkeys.api.mapper.RegistrationResponseMapper; import com.helioauth.passkeys.api.mapper.UserCredentialMapper; import com.helioauth.passkeys.api.service.dto.AssertionStartResult; import com.helioauth.passkeys.api.service.dto.CredentialRegistrationResult; import com.helioauth.passkeys.api.service.exception.SignUpFailedException; import com.helioauth.passkeys.api.service.exception.UsernameAlreadyRegisteredException; -import com.yubico.webauthn.data.ByteArray; // Added import +import com.yubico.webauthn.data.ByteArray; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mapstruct.factory.Mappers; @@ -29,9 +29,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; // Keep this -import static org.mockito.ArgumentMatchers.anyString; // Keep this -import static org.mockito.ArgumentMatchers.eq; // Added import +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -52,7 +52,6 @@ class UserSignupServiceTest { @Spy private UserCredentialMapper userCredentialMapper = Mappers.getMapper(UserCredentialMapper.class); - // Added mock for RegistrationResponseMapper as it's used in startRegistration @Mock private RegistrationResponseMapper registrationResponseMapper; @@ -63,27 +62,23 @@ class UserSignupServiceTest { void testStartRegistration_Success() throws Exception { // Arrange String name = "testuser"; - String rpId = "test-rp.com"; // Added dummy rpId + String rpId = "test-rp.com"; AssertionStartResult mockAssertionResult = new AssertionStartResult("requestId123", "{\"options\":\"value\"}"); SignUpStartResponse mockMappedResponse = new SignUpStartResponse("requestId123", "{\"options\":\"value\"}"); - // Mock the 3-argument call made internally by the service when(webAuthnAuthenticator.startRegistration(eq(name), any(ByteArray.class), eq(rpId))) .thenReturn(mockAssertionResult); - // Mock the mapper call when(registrationResponseMapper.toSignUpStartResponse(mockAssertionResult)) .thenReturn(mockMappedResponse); // Act - // Call the service method with both arguments SignUpStartResponse response = userSignupService.startRegistration(name, rpId); // Assert assertNotNull(response); assertEquals("requestId123", response.getRequestId()); assertEquals("{\"options\":\"value\"}", response.getOptions()); - // Verify the 3-argument internal call was made verify(webAuthnAuthenticator, times(1)).startRegistration(eq(name), any(ByteArray.class), eq(rpId)); verify(registrationResponseMapper, times(1)).toSignUpStartResponse(mockAssertionResult); } @@ -92,15 +87,13 @@ void testStartRegistration_Success() throws Exception { void testStartRegistration_ThrowsSignUpFailedException() throws Exception { // Arrange String name = "testuser"; - String rpId = "test-rp.com"; // Added dummy rpId - // Mock the 3-argument call made internally to throw the exception + String rpId = "test-rp.com"; when(webAuthnAuthenticator.startRegistration(eq(name), any(ByteArray.class), eq(rpId))) .thenThrow(JsonProcessingException.class); // Act & Assert - // Call the service method with both arguments inside the lambda assertThrows(SignUpFailedException.class, () -> userSignupService.startRegistration(name, rpId)); - verify(registrationResponseMapper, never()).toSignUpStartResponse(any()); // Mapper should not be called + verify(registrationResponseMapper, never()).toSignUpStartResponse(any()); } @Test @@ -138,7 +131,6 @@ void testFinishRegistration_Success() throws Exception { assertNotNull(response); assertEquals(requestId, response.getRequestId()); assertNotNull(response.getUserId()); - // Verify mapper was called verify(userCredentialMapper, times(1)).fromCredentialRegistrationResult(mockResult); } @@ -169,7 +161,6 @@ void testFinishRegistration_ThrowsSignUpFailedException_OnGetUsername() throws E String requestId = "requestId123"; String publicKeyCredentialJson = "{\"key\":\"value\"}"; - // Simulate failure during getUsernameByRequestId when(webAuthnAuthenticator.getUsernameByRequestId(requestId)).thenThrow(IOException.class); // Act & Assert @@ -177,7 +168,7 @@ void testFinishRegistration_ThrowsSignUpFailedException_OnGetUsername() throws E () -> userSignupService.finishRegistration(requestId, publicKeyCredentialJson) ); - verify(userRepository, never()).findByName(anyString()); // Should not proceed to check user existence + verify(userRepository, never()).findByName(anyString()); verify(webAuthnAuthenticator, never()).finishRegistration(anyString(), anyString()); verify(userRepository, never()).save(any(User.class)); verify(userCredentialRepository, never()).save(any(UserCredential.class)); @@ -192,7 +183,6 @@ void testFinishRegistration_ThrowsSignUpFailedException_OnFinishRegistration() t when(webAuthnAuthenticator.getUsernameByRequestId(requestId)).thenReturn(username); when(userRepository.findByName(username)).thenReturn(Optional.empty()); - // Simulate failure during finishRegistration itself when(webAuthnAuthenticator.finishRegistration(requestId, publicKeyCredentialJson)).thenThrow(IOException.class); // Act & Assert @@ -200,7 +190,7 @@ void testFinishRegistration_ThrowsSignUpFailedException_OnFinishRegistration() t () -> userSignupService.finishRegistration(requestId, publicKeyCredentialJson) ); - verify(userRepository, never()).save(any(User.class)); // Should not proceed to save user - verify(userCredentialRepository, never()).save(any(UserCredential.class)); // Should not proceed to save credential + verify(userRepository, never()).save(any(User.class)); + verify(userCredentialRepository, never()).save(any(UserCredential.class)); } } diff --git a/src/test/java/com/helioauth/passkeys/api/service/WebAuthnAuthenticatorTest.java b/src/test/java/com/helioauth/passkeys/api/service/WebAuthnAuthenticatorTest.java index 6cdb190..7158ab4 100644 --- a/src/test/java/com/helioauth/passkeys/api/service/WebAuthnAuthenticatorTest.java +++ b/src/test/java/com/helioauth/passkeys/api/service/WebAuthnAuthenticatorTest.java @@ -22,22 +22,16 @@ import com.helioauth.passkeys.api.service.dto.AssertionStartResult; import com.helioauth.passkeys.api.service.dto.CredentialRegistrationResult; import com.helioauth.passkeys.api.service.exception.CredentialRegistrationFailedException; -import com.yubico.webauthn.FinishRegistrationOptions; import com.yubico.webauthn.RegistrationResult; -import com.yubico.webauthn.RelyingParty; -import com.yubico.webauthn.StartRegistrationOptions; import com.yubico.webauthn.data.ByteArray; -import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions; -import com.yubico.webauthn.data.RelyingPartyIdentity; -// Remove unused import: import com.yubico.webauthn.CredentialRepository; -import com.helioauth.passkeys.api.webauthn.DatabaseCredentialRepository; // Add import for the correct type -import com.helioauth.passkeys.api.mapper.RegistrationResponseMapper; // Add import for the missing spy -import com.yubico.webauthn.data.AuthenticatorAttestationResponse; // Import AuthenticatorAttestationResponse +import com.helioauth.passkeys.api.webauthn.DatabaseCredentialRepository; +import com.helioauth.passkeys.api.mapper.RegistrationResponseMapper; +import com.yubico.webauthn.data.AuthenticatorAttestationResponse; import com.yubico.webauthn.data.UserIdentity; import com.yubico.webauthn.data.exception.HexException; import com.yubico.webauthn.exception.RegistrationFailedException; -import com.helioauth.passkeys.api.config.properties.WebAuthnRelyingPartyProperties; // Import properties -import org.junit.jupiter.api.BeforeEach; // Import BeforeEach +import com.helioauth.passkeys.api.config.properties.WebAuthnRelyingPartyProperties; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mapstruct.factory.Mappers; @@ -47,18 +41,14 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.io.IOException; -import java.util.List; -import java.util.Set; // Import Set - -import static org.junit.jupiter.api.Assertions.assertEquals; // Import assertEquals +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; // Import assertTrue +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.anyString; -import static org.mockito.Mockito.doReturn; // Import doReturn -import static org.mockito.Mockito.doThrow; // Import doThrow -import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -68,7 +58,7 @@ public class WebAuthnAuthenticatorTest { private static final String TEST_USER_NAME = "test3"; private static final ByteArray TEST_USER_ID = new ByteArray(new byte[]{1, 2, 3}); - private static final String TEST_RP_ID = "localhost"; // Add RP constants + private static final String TEST_RP_ID = "localhost"; private static final String TEST_RP_NAME = "Test RP"; private static final UserIdentity USER_IDENTITY = UserIdentity.builder() .name(TEST_USER_NAME) @@ -120,18 +110,15 @@ public class WebAuthnAuthenticatorTest { @Mock private DatabaseCredentialRepository databaseCredentialRepository; - @Spy // Keep Spy for the mapper + @Spy private CredentialRegistrationResultMapper credentialRegistrationResultMapper = Mappers.getMapper(CredentialRegistrationResultMapper.class); - // Add the missing spy @Spy private RegistrationResponseMapper registrationResponseMapper = Mappers.getMapper(RegistrationResponseMapper.class); @InjectMocks private WebAuthnAuthenticator authenticator; - // Remove setUpRelyingPartyIdentityMock method - @BeforeEach void setUp() { relyingPartyProperties.setHostname(TEST_RP_ID); @@ -141,30 +128,21 @@ void setUp() { @Test public void testStartRegistrationWithNameAndUserId() throws JsonProcessingException, HexException { - // RelyingParty is built inside authenticator.startRegistration - - // Execute the test - // Use the 3-arg version for clarity, passing the mocked RP ID AssertionStartResult response = authenticator.startRegistration(TEST_USER_NAME, TEST_USER_ID, TEST_RP_ID); - // Verify interactions and assert results verify(webAuthnRequestCache, times(1)).put(anyString(), anyString()); assertNotNull(response); assertNotNull(response.requestId()); assertNotNull(response.options()); - // Optionally, assert specific options based on the mocked properties + assertTrue(response.options().contains("\"id\":\"" + TEST_RP_ID + "\"")); assertTrue(response.options().contains("\"name\":\"" + TEST_RP_NAME + "\"")); } @Test public void testStartRegistrationWithName() throws JsonProcessingException, HexException { - // RelyingParty is built inside authenticator.startRegistration using default hostname from properties - - // Execute the test AssertionStartResult response = authenticator.startRegistration(TEST_USER_NAME); - // Verify interactions and assert results verify(webAuthnRequestCache, times(1)).put(anyString(), anyString()); assertNotNull(response); assertNotNull(response.requestId()); @@ -177,25 +155,22 @@ public void testStartRegistrationWithName() throws JsonProcessingException, HexE public void testFinishRegistrationSuccess() throws IOException, RegistrationFailedException { String requestId = "requestId"; - // Prepare the expected result from the mapper CredentialRegistrationResult mappedResult = new CredentialRegistrationResult("name", "displayName", "credentialId", "userHandle", 1L, "publicKeyCose", "attestationObject", "clientDataJson", true, true, true); - // Stub the spy's method using doReturn/when syntax doReturn(mappedResult).when(credentialRegistrationResultMapper) .fromRegistrationResult( any(RegistrationResult.class), any(UserIdentity.class), - any(AuthenticatorAttestationResponse.class) // Use specific type + any(AuthenticatorAttestationResponse.class) ); when(webAuthnRequestCache.getIfPresent(requestId)).thenReturn(AUTHENTICATOR_REQUEST_JSON); - // Internal call to PublicKeyCredential.parseRegistrationResponseJson happens CredentialRegistrationResult result = authenticator.finishRegistration(requestId, AUTHENTICATOR_RESPONSE_JSON); assertNotNull(result); - assertEquals(mappedResult.name(), result.name()); // Check mapped result (which came from the stub) + assertEquals(mappedResult.name(), result.name()); verify(webAuthnRequestCache, times(1)).invalidate(requestId); } @@ -219,7 +194,6 @@ public void testFinishRegistrationThrowsException() throws RegistrationFailedExc String requestId = "requestId"; when(webAuthnRequestCache.getIfPresent(requestId)).thenReturn(AUTHENTICATOR_REQUEST_JSON); - // Simulate failure during mapping using doThrow/when syntax for the spy doThrow(new RuntimeException("Simulated mapping error")).when(credentialRegistrationResultMapper) .fromRegistrationResult( any(RegistrationResult.class), @@ -227,7 +201,6 @@ public void testFinishRegistrationThrowsException() throws RegistrationFailedExc any(AuthenticatorAttestationResponse.class) // Use specific type ); - // Assert that our service wraps the internal exception CredentialRegistrationFailedException exception = assertThrows( CredentialRegistrationFailedException.class, () -> authenticator.finishRegistration(requestId, AUTHENTICATOR_RESPONSE_JSON) From 0b1b48ce1a2122236fa2ef72da602b31a5b2b82a Mon Sep 17 00:00:00 2001 From: Victor Stanchev Date: Sat, 19 Apr 2025 16:15:13 +0300 Subject: [PATCH 14/21] feat: Revert allowed origins in apps --- .../schemas/AddApplicationRequest.yaml | 5 +-- .../components/schemas/Application.yaml | 3 -- .../schemas/EditApplicationRequest.yaml | 5 +-- .../api/config/WebSecurityConfig.java | 35 ++----------------- .../api/controller/CredentialsController.java | 2 ++ .../api/domain/ClientApplication.java | 3 -- .../api/service/ClientApplicationService.java | 1 - .../api/service/WebAuthnAuthenticator.java | 10 ++---- 8 files changed, 9 insertions(+), 55 deletions(-) diff --git a/docs/openapi/components/schemas/AddApplicationRequest.yaml b/docs/openapi/components/schemas/AddApplicationRequest.yaml index d6d724a..039cc73 100644 --- a/docs/openapi/components/schemas/AddApplicationRequest.yaml +++ b/docs/openapi/components/schemas/AddApplicationRequest.yaml @@ -6,7 +6,4 @@ properties: description: Name of the new application. relyingPartyHostname: type: string - description: Hostname of the application, e.g. example.com - allowedOrigins: - type: string - description: Allowed origins for the application, comma separated \ No newline at end of file + description: Hostname of the application, e.g. example.com \ No newline at end of file diff --git a/docs/openapi/components/schemas/Application.yaml b/docs/openapi/components/schemas/Application.yaml index 9e80e83..abf6630 100644 --- a/docs/openapi/components/schemas/Application.yaml +++ b/docs/openapi/components/schemas/Application.yaml @@ -19,6 +19,3 @@ properties: relyingPartyHostname: type: string description: Hostname of the relying party. - allowedOrigins: - type: string - description: Allowed origins for the application. diff --git a/docs/openapi/components/schemas/EditApplicationRequest.yaml b/docs/openapi/components/schemas/EditApplicationRequest.yaml index 0ef183f..fee86b6 100644 --- a/docs/openapi/components/schemas/EditApplicationRequest.yaml +++ b/docs/openapi/components/schemas/EditApplicationRequest.yaml @@ -6,7 +6,4 @@ properties: description: Name of the application. relyingPartyHostname: type: string - description: Hostname of the relying party. - allowedOrigins: - type: string - description: Allowed origins for the application, comma separated \ No newline at end of file + description: Hostname of the relying party. \ No newline at end of file diff --git a/src/main/java/com/helioauth/passkeys/api/config/WebSecurityConfig.java b/src/main/java/com/helioauth/passkeys/api/config/WebSecurityConfig.java index a1048b6..dc85ed5 100644 --- a/src/main/java/com/helioauth/passkeys/api/config/WebSecurityConfig.java +++ b/src/main/java/com/helioauth/passkeys/api/config/WebSecurityConfig.java @@ -26,6 +26,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; @@ -37,11 +38,6 @@ import jakarta.servlet.http.HttpServletResponse; import java.util.List; -import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.CorsConfigurationSource; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import com.helioauth.passkeys.api.domain.ClientApplication; /** @@ -62,7 +58,7 @@ public SecurityFilterChain filterChain(HttpSecurity http, RequestHeaderAuthenticationFilter applicationApiKeyAuthFilter) throws Exception { http - .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .cors(Customizer.withDefaults()) .csrf(AbstractHttpConfigurer::disable) .sessionManagement(config -> config.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .addFilterAfter(adminAuthFilter, HeaderWriterFilter.class) @@ -125,31 +121,4 @@ protected AuthenticationManager appIdAuthenticationManager() { protected AuthenticationManager appApiKeyAuthenticationManager() { return new ProviderManager(List.of(applicationApiKeyAuthenticationProvider)); } - - @Bean - CorsConfigurationSource corsConfigurationSource() { - return _ -> { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication == null || !(authentication.getPrincipal() instanceof ClientApplication)) { - CorsConfiguration configuration = new CorsConfiguration(); - configuration.applyPermitDefaultValues(); - return configuration; - } - - ClientApplication clientApplication = (ClientApplication) authentication.getPrincipal(); - String allowedOrigins = clientApplication.getAllowedOrigins(); - - CorsConfiguration configuration = new CorsConfiguration(); - if (allowedOrigins != null && !allowedOrigins.isEmpty()) { - configuration.setAllowedOrigins(List.of(allowedOrigins.split(","))); - configuration.setAllowedMethods(List.of("*")); - configuration.setAllowedHeaders(List.of("*")); - configuration.setAllowCredentials(true); - } else { - configuration.applyPermitDefaultValues(); - } - - return configuration; - }; - } } diff --git a/src/main/java/com/helioauth/passkeys/api/controller/CredentialsController.java b/src/main/java/com/helioauth/passkeys/api/controller/CredentialsController.java index e079cbe..554b9ef 100644 --- a/src/main/java/com/helioauth/passkeys/api/controller/CredentialsController.java +++ b/src/main/java/com/helioauth/passkeys/api/controller/CredentialsController.java @@ -37,6 +37,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; @@ -49,6 +50,7 @@ @Slf4j @RestController @RequiredArgsConstructor +@CrossOrigin(origins = "*") public class CredentialsController implements SignUpApi, SignInApi { private final UserSignInService userSignInService; diff --git a/src/main/java/com/helioauth/passkeys/api/domain/ClientApplication.java b/src/main/java/com/helioauth/passkeys/api/domain/ClientApplication.java index f71d736..9b23357 100644 --- a/src/main/java/com/helioauth/passkeys/api/domain/ClientApplication.java +++ b/src/main/java/com/helioauth/passkeys/api/domain/ClientApplication.java @@ -69,9 +69,6 @@ public class ClientApplication { @Column(name = "relying_party_hostname", nullable = true) private String relyingPartyHostname; - @Column(name = "allowed_origins", nullable = true) - private String allowedOrigins; - @CreatedDate @Temporal(TemporalType.TIMESTAMP) @Column(name = "created_at", nullable = false) diff --git a/src/main/java/com/helioauth/passkeys/api/service/ClientApplicationService.java b/src/main/java/com/helioauth/passkeys/api/service/ClientApplicationService.java index 70c22e2..1c43bf1 100644 --- a/src/main/java/com/helioauth/passkeys/api/service/ClientApplicationService.java +++ b/src/main/java/com/helioauth/passkeys/api/service/ClientApplicationService.java @@ -68,7 +68,6 @@ public Application add(AddApplicationRequest request) { .name(request.getName()) .apiKey(generateApiKey()) .relyingPartyHostname(request.getRelyingPartyHostname()) - .allowedOrigins(request.getAllowedOrigins()) .build() ) ); diff --git a/src/main/java/com/helioauth/passkeys/api/service/WebAuthnAuthenticator.java b/src/main/java/com/helioauth/passkeys/api/service/WebAuthnAuthenticator.java index 72df234..1b0feef 100644 --- a/src/main/java/com/helioauth/passkeys/api/service/WebAuthnAuthenticator.java +++ b/src/main/java/com/helioauth/passkeys/api/service/WebAuthnAuthenticator.java @@ -18,15 +18,14 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.github.benmanes.caffeine.cache.Cache; -import com.helioauth.passkeys.api.config.properties.WebAuthnRelyingPartyProperties; // Added import +import com.helioauth.passkeys.api.config.properties.WebAuthnRelyingPartyProperties; import com.helioauth.passkeys.api.mapper.CredentialRegistrationResultMapper; -import com.helioauth.passkeys.api.mapper.RegistrationResponseMapper; import com.helioauth.passkeys.api.service.dto.AssertionStartResult; import com.helioauth.passkeys.api.service.dto.CredentialAssertionResult; import com.helioauth.passkeys.api.service.dto.CredentialRegistrationResult; import com.helioauth.passkeys.api.service.exception.CredentialAssertionFailedException; import com.helioauth.passkeys.api.service.exception.CredentialRegistrationFailedException; -import com.helioauth.passkeys.api.webauthn.DatabaseCredentialRepository; // Added import +import com.helioauth.passkeys.api.webauthn.DatabaseCredentialRepository; import com.yubico.webauthn.AssertionRequest; import com.yubico.webauthn.AssertionResult; import com.yubico.webauthn.FinishAssertionOptions; @@ -44,11 +43,10 @@ import com.yubico.webauthn.data.ClientRegistrationExtensionOutputs; import com.yubico.webauthn.data.PublicKeyCredential; import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions; -import com.yubico.webauthn.data.RelyingPartyIdentity; // Added import +import com.yubico.webauthn.data.RelyingPartyIdentity; import com.yubico.webauthn.data.ResidentKeyRequirement; import com.yubico.webauthn.data.UserIdentity; import com.yubico.webauthn.exception.AssertionFailedException; -import com.yubico.webauthn.exception.RegistrationFailedException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -72,8 +70,6 @@ public class WebAuthnAuthenticator { private final Cache webAuthnRequestCache; private static final SecureRandom random = new SecureRandom(); - private final RegistrationResponseMapper registrationResponseMapper; - public AssertionStartResult startRegistration(String name) throws JsonProcessingException { ByteArray id = generateRandom(); return startRegistration(name, id); From 05ced0812d03bc55a439ced86199015020b1f601 Mon Sep 17 00:00:00 2001 From: Victor Stanchev Date: Sat, 19 Apr 2025 16:19:04 +0300 Subject: [PATCH 15/21] refactor: Use mapper when updating app --- .../passkeys/api/mapper/ClientApplicationMapper.java | 4 ++++ .../passkeys/api/service/ClientApplicationService.java | 4 +--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/helioauth/passkeys/api/mapper/ClientApplicationMapper.java b/src/main/java/com/helioauth/passkeys/api/mapper/ClientApplicationMapper.java index 5c30901..66263e2 100644 --- a/src/main/java/com/helioauth/passkeys/api/mapper/ClientApplicationMapper.java +++ b/src/main/java/com/helioauth/passkeys/api/mapper/ClientApplicationMapper.java @@ -19,8 +19,10 @@ import com.helioauth.passkeys.api.domain.ClientApplication; import com.helioauth.passkeys.api.generated.models.Application; import com.helioauth.passkeys.api.generated.models.ApplicationApiKey; +import com.helioauth.passkeys.api.generated.models.EditApplicationRequest; import org.mapstruct.Mapper; import org.mapstruct.MappingConstants; +import org.mapstruct.MappingTarget; import java.util.List; @@ -33,4 +35,6 @@ public interface ClientApplicationMapper { List toResponse(List clientApplication); ApplicationApiKey toApiKeyResponse(ClientApplication clientApplication); + + void updateClientApplication(@MappingTarget ClientApplication clientApplication, EditApplicationRequest request); } diff --git a/src/main/java/com/helioauth/passkeys/api/service/ClientApplicationService.java b/src/main/java/com/helioauth/passkeys/api/service/ClientApplicationService.java index 1c43bf1..16ff1ba 100644 --- a/src/main/java/com/helioauth/passkeys/api/service/ClientApplicationService.java +++ b/src/main/java/com/helioauth/passkeys/api/service/ClientApplicationService.java @@ -77,9 +77,7 @@ public Application add(AddApplicationRequest request) { public Optional edit(UUID id, EditApplicationRequest request) { return repository.findById(id) .map(existing -> { - existing.setName(request.getName()); - existing.setRelyingPartyHostname(request.getRelyingPartyHostname()); - existing.setAllowedOrigins(request.getAllowedOrigins()); + clientApplicationMapper.updateClientApplication(existing, request); return clientApplicationMapper.toResponse(repository.save(existing)); }); } From 0b5cb8e21ddb23b423ffbf33d61256d4bb3e9048 Mon Sep 17 00:00:00 2001 From: Victor Stanchev Date: Mon, 21 Apr 2025 10:02:37 +0300 Subject: [PATCH 16/21] feat: Get relying party name from application --- .../api/controller/CredentialsController.java | 3 ++- .../passkeys/api/service/UserSignupService.java | 8 ++++++-- .../api/service/WebAuthnAuthenticator.java | 14 +++++++++++--- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/helioauth/passkeys/api/controller/CredentialsController.java b/src/main/java/com/helioauth/passkeys/api/controller/CredentialsController.java index 554b9ef..62802ed 100644 --- a/src/main/java/com/helioauth/passkeys/api/controller/CredentialsController.java +++ b/src/main/java/com/helioauth/passkeys/api/controller/CredentialsController.java @@ -64,6 +64,7 @@ public ResponseEntity postSignupStart(@RequestBody @Valid S } String rpId = clientApp.getRelyingPartyHostname(); + String rpName = clientApp.getRelyingPartyName(); if (rpId == null || rpId.isBlank()) { log.error("Authenticated ClientApplication (ID: {}) is missing a valid relyingPartyHostname.", clientApp.getId()); @@ -71,7 +72,7 @@ public ResponseEntity postSignupStart(@RequestBody @Valid S } return ResponseEntity.ok( - userSignupService.startRegistration(request.getName(), rpId) + userSignupService.startRegistration(request.getName(), rpId, rpName) ); } diff --git a/src/main/java/com/helioauth/passkeys/api/service/UserSignupService.java b/src/main/java/com/helioauth/passkeys/api/service/UserSignupService.java index deec02a..e83398c 100644 --- a/src/main/java/com/helioauth/passkeys/api/service/UserSignupService.java +++ b/src/main/java/com/helioauth/passkeys/api/service/UserSignupService.java @@ -51,6 +51,10 @@ public class UserSignupService { private final RegistrationResponseMapper registrationResponseMapper; public SignUpStartResponse startRegistration(String name, String rpId) { + return startRegistration(name, rpId, null); + } + + public SignUpStartResponse startRegistration(String name, String rpId, String rpName) { if (userRepository.findByName(name).isPresent()) { log.warn("Attempted to start registration for already existing username: {}", name); throw new UsernameAlreadyRegisteredException(); @@ -59,10 +63,10 @@ public SignUpStartResponse startRegistration(String name, String rpId) { try { ByteArray userId = WebAuthnAuthenticator.generateRandom(); return registrationResponseMapper.toSignUpStartResponse( - webAuthnAuthenticator.startRegistration(name, userId, rpId) + webAuthnAuthenticator.startRegistration(name, userId, rpId, rpName) ); } catch (JsonProcessingException e) { - log.error("Register Credential failed for user '{}' and rpId '{}'", name, rpId, e); + log.error("Register Credential failed for user '{}' and rpId '{}' with name '{}'", name, rpId, rpName, e); throw new SignUpFailedException(); } } diff --git a/src/main/java/com/helioauth/passkeys/api/service/WebAuthnAuthenticator.java b/src/main/java/com/helioauth/passkeys/api/service/WebAuthnAuthenticator.java index 1b0feef..153d11f 100644 --- a/src/main/java/com/helioauth/passkeys/api/service/WebAuthnAuthenticator.java +++ b/src/main/java/com/helioauth/passkeys/api/service/WebAuthnAuthenticator.java @@ -80,9 +80,13 @@ public AssertionStartResult startRegistration(String name, ByteArray userId) thr } public AssertionStartResult startRegistration(String name, ByteArray userId, String rpHostname) throws JsonProcessingException { - log.debug("Starting registration for user '{}' with id '{}' for RP '{}'", name, userId.getBase64Url(), rpHostname); + return startRegistration(name, userId, rpHostname, null); + } + + public AssertionStartResult startRegistration(String name, ByteArray userId, String rpHostname, String rpName) throws JsonProcessingException { + log.debug("Starting registration for user '{}' with id '{}' for RP '{}' with name '{}'", name, userId.getBase64Url(), rpHostname, rpName); - RelyingParty relyingParty = buildRelyingParty(rpHostname); + RelyingParty relyingParty = buildRelyingParty(rpHostname, rpName); ResidentKeyRequirement residentKeyRequirement = ResidentKeyRequirement.PREFERRED; @@ -206,9 +210,13 @@ public CredentialAssertionResult finishAssertion(String requestId, String public } private RelyingParty buildRelyingParty(String rpHostname) { + return buildRelyingParty(rpHostname, null); + } + + private RelyingParty buildRelyingParty(String rpHostname, String rpName) { RelyingPartyIdentity rpIdentity = RelyingPartyIdentity.builder() .id(rpHostname) - .name(relyingPartyProperties.getDisplayName()) + .name(rpName != null ? rpName : relyingPartyProperties.getDisplayName()) .build(); return RelyingParty.builder() From d090d940900e5b84b83b80a6dfab2ed4b73ae486 Mon Sep 17 00:00:00 2001 From: Victor Stanchev Date: Mon, 21 Apr 2025 10:03:32 +0300 Subject: [PATCH 17/21] test: Update test with relying party name --- .../api/service/UserSignupServiceTest.java | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/test/java/com/helioauth/passkeys/api/service/UserSignupServiceTest.java b/src/test/java/com/helioauth/passkeys/api/service/UserSignupServiceTest.java index 2c49c29..26ffe38 100644 --- a/src/test/java/com/helioauth/passkeys/api/service/UserSignupServiceTest.java +++ b/src/test/java/com/helioauth/passkeys/api/service/UserSignupServiceTest.java @@ -42,13 +42,13 @@ class UserSignupServiceTest { @Mock private WebAuthnAuthenticator webAuthnAuthenticator; - + @Mock private UserRepository userRepository; - + @Mock private UserCredentialRepository userCredentialRepository; - + @Spy private UserCredentialMapper userCredentialMapper = Mappers.getMapper(UserCredentialMapper.class); @@ -66,7 +66,7 @@ void testStartRegistration_Success() throws Exception { AssertionStartResult mockAssertionResult = new AssertionStartResult("requestId123", "{\"options\":\"value\"}"); SignUpStartResponse mockMappedResponse = new SignUpStartResponse("requestId123", "{\"options\":\"value\"}"); - when(webAuthnAuthenticator.startRegistration(eq(name), any(ByteArray.class), eq(rpId))) + when(webAuthnAuthenticator.startRegistration(eq(name), any(ByteArray.class), eq(rpId), eq(null))) .thenReturn(mockAssertionResult); when(registrationResponseMapper.toSignUpStartResponse(mockAssertionResult)) .thenReturn(mockMappedResponse); @@ -79,7 +79,7 @@ void testStartRegistration_Success() throws Exception { assertNotNull(response); assertEquals("requestId123", response.getRequestId()); assertEquals("{\"options\":\"value\"}", response.getOptions()); - verify(webAuthnAuthenticator, times(1)).startRegistration(eq(name), any(ByteArray.class), eq(rpId)); + verify(webAuthnAuthenticator, times(1)).startRegistration(eq(name), any(ByteArray.class), eq(rpId), eq(null)); verify(registrationResponseMapper, times(1)).toSignUpStartResponse(mockAssertionResult); } @@ -88,7 +88,7 @@ void testStartRegistration_ThrowsSignUpFailedException() throws Exception { // Arrange String name = "testuser"; String rpId = "test-rp.com"; - when(webAuthnAuthenticator.startRegistration(eq(name), any(ByteArray.class), eq(rpId))) + when(webAuthnAuthenticator.startRegistration(eq(name), any(ByteArray.class), eq(rpId), eq(null))) .thenThrow(JsonProcessingException.class); // Act & Assert @@ -110,7 +110,7 @@ void testFinishRegistration_Success() throws Exception { "", "","", true, true, true ); - + User mockUser = User.builder() .id(UUID.randomUUID()) .name(username) From e47c1202c71f111f0971cc56a757c318784f13cb Mon Sep 17 00:00:00 2001 From: Victor Stanchev Date: Sun, 27 Apr 2025 08:01:30 +0300 Subject: [PATCH 18/21] refactor: unify registration handling with DTO request objects --- .../api/service/UserCredentialManager.java | 7 ++- .../api/service/UserSignInService.java | 5 +- .../api/service/UserSignupService.java | 10 +++- .../api/service/WebAuthnAuthenticator.java | 25 +++------ .../service/dto/RegistrationStartRequest.java | 55 +++++++++++++++++++ 5 files changed, 82 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/helioauth/passkeys/api/service/dto/RegistrationStartRequest.java diff --git a/src/main/java/com/helioauth/passkeys/api/service/UserCredentialManager.java b/src/main/java/com/helioauth/passkeys/api/service/UserCredentialManager.java index fd8e814..52c1f9e 100644 --- a/src/main/java/com/helioauth/passkeys/api/service/UserCredentialManager.java +++ b/src/main/java/com/helioauth/passkeys/api/service/UserCredentialManager.java @@ -28,6 +28,7 @@ import com.helioauth.passkeys.api.mapper.RegistrationResponseMapper; import com.helioauth.passkeys.api.mapper.UserCredentialMapper; import com.helioauth.passkeys.api.service.dto.CredentialRegistrationResult; +import com.helioauth.passkeys.api.service.dto.RegistrationStartRequest; import com.helioauth.passkeys.api.service.exception.CreateCredentialFailedException; import com.helioauth.passkeys.api.service.exception.SignUpFailedException; import lombok.RequiredArgsConstructor; @@ -54,7 +55,9 @@ public class UserCredentialManager { public SignUpStartResponse createCredential(String name) { try { return registrationResponseMapper.toSignUpStartResponse( - webAuthnAuthenticator.startRegistration(name) + webAuthnAuthenticator.startRegistration( + RegistrationStartRequest.withName(name).build() + ) ); } catch (JsonProcessingException e) { log.error("Creating a new credential failed", e); @@ -86,5 +89,5 @@ public ListPasskeysResponse getUserCredentials(UUID userUuid) { List userCredentials = userCredentialRepository.findAllByUserId(userUuid); return new ListPasskeysResponse(userCredentialMapper.toDto(userCredentials)); } - + } diff --git a/src/main/java/com/helioauth/passkeys/api/service/UserSignInService.java b/src/main/java/com/helioauth/passkeys/api/service/UserSignInService.java index 395e52b..f2bc6a5 100644 --- a/src/main/java/com/helioauth/passkeys/api/service/UserSignInService.java +++ b/src/main/java/com/helioauth/passkeys/api/service/UserSignInService.java @@ -22,6 +22,7 @@ import com.helioauth.passkeys.api.generated.models.SignInStartResponse; import com.helioauth.passkeys.api.mapper.RegistrationResponseMapper; import com.helioauth.passkeys.api.service.dto.CredentialAssertionResult; +import com.helioauth.passkeys.api.service.dto.RegistrationStartRequest; import com.helioauth.passkeys.api.service.exception.SignInFailedException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -49,7 +50,9 @@ public class UserSignInService { public SignInStartResponse startAssertion(String name) throws JsonProcessingException { if (!StringUtils.isBlank(name) && userRepository.findByName(name).isEmpty()) { return registrationResponseMapper.toSignInStartResponse( - webAuthnAuthenticator.startRegistration(name), + webAuthnAuthenticator.startRegistration( + RegistrationStartRequest.withName(name).build() + ), false ); } diff --git a/src/main/java/com/helioauth/passkeys/api/service/UserSignupService.java b/src/main/java/com/helioauth/passkeys/api/service/UserSignupService.java index e83398c..5c41ff3 100644 --- a/src/main/java/com/helioauth/passkeys/api/service/UserSignupService.java +++ b/src/main/java/com/helioauth/passkeys/api/service/UserSignupService.java @@ -26,6 +26,7 @@ import com.helioauth.passkeys.api.mapper.RegistrationResponseMapper; import com.helioauth.passkeys.api.mapper.UserCredentialMapper; import com.helioauth.passkeys.api.service.dto.CredentialRegistrationResult; +import com.helioauth.passkeys.api.service.dto.RegistrationStartRequest; import com.helioauth.passkeys.api.service.exception.SignUpFailedException; import com.helioauth.passkeys.api.service.exception.UsernameAlreadyRegisteredException; import com.yubico.webauthn.data.ByteArray; @@ -63,7 +64,14 @@ public SignUpStartResponse startRegistration(String name, String rpId, String rp try { ByteArray userId = WebAuthnAuthenticator.generateRandom(); return registrationResponseMapper.toSignUpStartResponse( - webAuthnAuthenticator.startRegistration(name, userId, rpId, rpName) + webAuthnAuthenticator.startRegistration( + RegistrationStartRequest.builder() + .name(name) + .userId(userId) + .rpHostname(rpId) + .rpName(rpName) + .build() + ) ); } catch (JsonProcessingException e) { log.error("Register Credential failed for user '{}' and rpId '{}' with name '{}'", name, rpId, rpName, e); diff --git a/src/main/java/com/helioauth/passkeys/api/service/WebAuthnAuthenticator.java b/src/main/java/com/helioauth/passkeys/api/service/WebAuthnAuthenticator.java index 153d11f..6c2f96b 100644 --- a/src/main/java/com/helioauth/passkeys/api/service/WebAuthnAuthenticator.java +++ b/src/main/java/com/helioauth/passkeys/api/service/WebAuthnAuthenticator.java @@ -23,6 +23,7 @@ import com.helioauth.passkeys.api.service.dto.AssertionStartResult; import com.helioauth.passkeys.api.service.dto.CredentialAssertionResult; import com.helioauth.passkeys.api.service.dto.CredentialRegistrationResult; +import com.helioauth.passkeys.api.service.dto.RegistrationStartRequest; import com.helioauth.passkeys.api.service.exception.CredentialAssertionFailedException; import com.helioauth.passkeys.api.service.exception.CredentialRegistrationFailedException; import com.helioauth.passkeys.api.webauthn.DatabaseCredentialRepository; @@ -70,27 +71,19 @@ public class WebAuthnAuthenticator { private final Cache webAuthnRequestCache; private static final SecureRandom random = new SecureRandom(); - public AssertionStartResult startRegistration(String name) throws JsonProcessingException { - ByteArray id = generateRandom(); - return startRegistration(name, id); - } - - public AssertionStartResult startRegistration(String name, ByteArray userId) throws JsonProcessingException { - return startRegistration(name, userId, relyingPartyProperties.getHostname()); - } + public AssertionStartResult startRegistration(RegistrationStartRequest request) throws JsonProcessingException { + String name = request.getName(); + ByteArray userId = request.getUserId() != null ? request.getUserId() : generateRandom(); + String rpHostname = request.getRpHostname() != null ? request.getRpHostname() : relyingPartyProperties.getHostname(); + String rpName = request.getRpName(); - public AssertionStartResult startRegistration(String name, ByteArray userId, String rpHostname) throws JsonProcessingException { - return startRegistration(name, userId, rpHostname, null); - } - - public AssertionStartResult startRegistration(String name, ByteArray userId, String rpHostname, String rpName) throws JsonProcessingException { log.debug("Starting registration for user '{}' with id '{}' for RP '{}' with name '{}'", name, userId.getBase64Url(), rpHostname, rpName); RelyingParty relyingParty = buildRelyingParty(rpHostname, rpName); ResidentKeyRequirement residentKeyRequirement = ResidentKeyRequirement.PREFERRED; - PublicKeyCredentialCreationOptions request = relyingParty.startRegistration(StartRegistrationOptions.builder() + PublicKeyCredentialCreationOptions creationOptions = relyingParty.startRegistration(StartRegistrationOptions.builder() .user( UserIdentity.builder() .name(name) @@ -107,9 +100,9 @@ public AssertionStartResult startRegistration(String name, ByteArray userId, Str ); String requestId = generateRandom().getHex(); - webAuthnRequestCache.put(requestId, request.toJson()); + webAuthnRequestCache.put(requestId, creationOptions.toJson()); - return new AssertionStartResult(requestId, request.toCredentialsCreateJson()); + return new AssertionStartResult(requestId, creationOptions.toCredentialsCreateJson()); } public String getUsernameByRequestId(String requestId) throws IOException { diff --git a/src/main/java/com/helioauth/passkeys/api/service/dto/RegistrationStartRequest.java b/src/main/java/com/helioauth/passkeys/api/service/dto/RegistrationStartRequest.java new file mode 100644 index 0000000..67c5124 --- /dev/null +++ b/src/main/java/com/helioauth/passkeys/api/service/dto/RegistrationStartRequest.java @@ -0,0 +1,55 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.helioauth.passkeys.api.service.dto; + +import com.yubico.webauthn.data.ByteArray; +import lombok.Builder; +import lombok.Value; + +/** + * Data Transfer Object for starting a WebAuthn registration. + */ +@Value +@Builder +public class RegistrationStartRequest { + /** + * The username for the registration. + */ + String name; + + /** + * The user ID for the registration. If null, a random ID will be generated. + */ + ByteArray userId; + + /** + * The Relying Party hostname. If null, the default from properties will be used. + */ + String rpHostname; + + /** + * The Relying Party name. Can be null. + */ + String rpName; + + /** + * Creates a builder with only the name set. + */ + public static RegistrationStartRequestBuilder withName(String name) { + return builder().name(name); + } +} \ No newline at end of file From 01379eadef212733bc3af41763a77e48493614ba Mon Sep 17 00:00:00 2001 From: Victor Stanchev Date: Sun, 27 Apr 2025 08:04:41 +0300 Subject: [PATCH 19/21] refactor: update tests to use RegistrationStartRequest object --- .../service/UserCredentialManagerTest.java | 5 ++-- .../service/WebAuthnAuthenticatorTest.java | 28 ++++++++++++------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/test/java/com/helioauth/passkeys/api/service/UserCredentialManagerTest.java b/src/test/java/com/helioauth/passkeys/api/service/UserCredentialManagerTest.java index 676bc9c..b7aed2a 100644 --- a/src/test/java/com/helioauth/passkeys/api/service/UserCredentialManagerTest.java +++ b/src/test/java/com/helioauth/passkeys/api/service/UserCredentialManagerTest.java @@ -29,6 +29,7 @@ import com.helioauth.passkeys.api.mapper.UserCredentialMapper; import com.helioauth.passkeys.api.service.dto.AssertionStartResult; import com.helioauth.passkeys.api.service.dto.CredentialRegistrationResult; +import com.helioauth.passkeys.api.service.dto.RegistrationStartRequest; import com.helioauth.passkeys.api.service.exception.CreateCredentialFailedException; import com.helioauth.passkeys.api.service.exception.SignUpFailedException; import org.junit.jupiter.api.Test; @@ -123,7 +124,7 @@ void createCredential_returnsResponse_whenSuccessful() throws JsonProcessingExce // Arrange String userName = "testUser"; AssertionStartResult startResult = new AssertionStartResult("requestId", "{\"key\":\"value\"}"); // Renamed variable - when(authenticator.startRegistration(userName)).thenReturn(startResult); + when(authenticator.startRegistration(any(RegistrationStartRequest.class))).thenReturn(startResult); // Act SignUpStartResponse response = userCredentialManager.createCredential(userName); @@ -139,7 +140,7 @@ void createCredential_returnsResponse_whenSuccessful() throws JsonProcessingExce void createCredential_throwsException_whenJsonProcessingExceptionOccurs() throws JsonProcessingException { // Arrange String userName = "testUser"; - when(authenticator.startRegistration(userName)).thenThrow(JsonProcessingException.class); + when(authenticator.startRegistration(any(RegistrationStartRequest.class))).thenThrow(JsonProcessingException.class); // Act & Assert assertThrows(CreateCredentialFailedException.class, () -> userCredentialManager.createCredential(userName)); diff --git a/src/test/java/com/helioauth/passkeys/api/service/WebAuthnAuthenticatorTest.java b/src/test/java/com/helioauth/passkeys/api/service/WebAuthnAuthenticatorTest.java index 7158ab4..729a0be 100644 --- a/src/test/java/com/helioauth/passkeys/api/service/WebAuthnAuthenticatorTest.java +++ b/src/test/java/com/helioauth/passkeys/api/service/WebAuthnAuthenticatorTest.java @@ -18,19 +18,20 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.github.benmanes.caffeine.cache.Cache; +import com.helioauth.passkeys.api.config.properties.WebAuthnRelyingPartyProperties; import com.helioauth.passkeys.api.mapper.CredentialRegistrationResultMapper; +import com.helioauth.passkeys.api.mapper.RegistrationResponseMapper; import com.helioauth.passkeys.api.service.dto.AssertionStartResult; import com.helioauth.passkeys.api.service.dto.CredentialRegistrationResult; +import com.helioauth.passkeys.api.service.dto.RegistrationStartRequest; import com.helioauth.passkeys.api.service.exception.CredentialRegistrationFailedException; -import com.yubico.webauthn.RegistrationResult; -import com.yubico.webauthn.data.ByteArray; import com.helioauth.passkeys.api.webauthn.DatabaseCredentialRepository; -import com.helioauth.passkeys.api.mapper.RegistrationResponseMapper; +import com.yubico.webauthn.RegistrationResult; import com.yubico.webauthn.data.AuthenticatorAttestationResponse; +import com.yubico.webauthn.data.ByteArray; import com.yubico.webauthn.data.UserIdentity; import com.yubico.webauthn.data.exception.HexException; import com.yubico.webauthn.exception.RegistrationFailedException; -import com.helioauth.passkeys.api.config.properties.WebAuthnRelyingPartyProperties; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -41,6 +42,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.io.IOException; + import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -58,7 +60,7 @@ public class WebAuthnAuthenticatorTest { private static final String TEST_USER_NAME = "test3"; private static final ByteArray TEST_USER_ID = new ByteArray(new byte[]{1, 2, 3}); - private static final String TEST_RP_ID = "localhost"; + private static final String TEST_RP_HOSTNAME = "localhost"; private static final String TEST_RP_NAME = "Test RP"; private static final UserIdentity USER_IDENTITY = UserIdentity.builder() .name(TEST_USER_NAME) @@ -121,33 +123,39 @@ public class WebAuthnAuthenticatorTest { @BeforeEach void setUp() { - relyingPartyProperties.setHostname(TEST_RP_ID); + relyingPartyProperties.setHostname(TEST_RP_HOSTNAME); relyingPartyProperties.setDisplayName(TEST_RP_NAME); relyingPartyProperties.setAllowOriginPort(true); } @Test public void testStartRegistrationWithNameAndUserId() throws JsonProcessingException, HexException { - AssertionStartResult response = authenticator.startRegistration(TEST_USER_NAME, TEST_USER_ID, TEST_RP_ID); + AssertionStartResult response = authenticator.startRegistration( + RegistrationStartRequest.builder() + .name(TEST_USER_NAME) + .userId(TEST_USER_ID) + .rpHostname(TEST_RP_HOSTNAME) + .build() + ); verify(webAuthnRequestCache, times(1)).put(anyString(), anyString()); assertNotNull(response); assertNotNull(response.requestId()); assertNotNull(response.options()); - assertTrue(response.options().contains("\"id\":\"" + TEST_RP_ID + "\"")); + assertTrue(response.options().contains("\"id\":\"" + TEST_RP_HOSTNAME + "\"")); assertTrue(response.options().contains("\"name\":\"" + TEST_RP_NAME + "\"")); } @Test public void testStartRegistrationWithName() throws JsonProcessingException, HexException { - AssertionStartResult response = authenticator.startRegistration(TEST_USER_NAME); + AssertionStartResult response = authenticator.startRegistration(RegistrationStartRequest.withName(TEST_USER_NAME).build()); verify(webAuthnRequestCache, times(1)).put(anyString(), anyString()); assertNotNull(response); assertNotNull(response.requestId()); assertNotNull(response.options()); - assertTrue(response.options().contains("\"id\":\"" + TEST_RP_ID + "\"")); + assertTrue(response.options().contains("\"id\":\"" + TEST_RP_HOSTNAME + "\"")); assertTrue(response.options().contains("\"name\":\"" + TEST_RP_NAME + "\"")); } From c0d1233edd88901eec48e0cd0ad1595f356bd1fe Mon Sep 17 00:00:00 2001 From: Victor Stanchev Date: Sun, 27 Apr 2025 08:12:02 +0300 Subject: [PATCH 20/21] refactor: introduce UserSignupStartRequest DTO for signup flow --- .../api/controller/CredentialsController.java | 8 ++- .../api/service/UserSignupService.java | 9 ++-- .../service/dto/UserSignupStartRequest.java | 49 +++++++++++++++++++ 3 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/helioauth/passkeys/api/service/dto/UserSignupStartRequest.java diff --git a/src/main/java/com/helioauth/passkeys/api/controller/CredentialsController.java b/src/main/java/com/helioauth/passkeys/api/controller/CredentialsController.java index 62802ed..a7c6d20 100644 --- a/src/main/java/com/helioauth/passkeys/api/controller/CredentialsController.java +++ b/src/main/java/com/helioauth/passkeys/api/controller/CredentialsController.java @@ -30,6 +30,7 @@ import com.helioauth.passkeys.api.generated.models.SignUpStartResponse; import com.helioauth.passkeys.api.service.UserSignInService; import com.helioauth.passkeys.api.service.UserSignupService; +import com.helioauth.passkeys.api.service.dto.UserSignupStartRequest; import com.helioauth.passkeys.api.service.exception.SignInFailedException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -72,7 +73,12 @@ public ResponseEntity postSignupStart(@RequestBody @Valid S } return ResponseEntity.ok( - userSignupService.startRegistration(request.getName(), rpId, rpName) + userSignupService.startRegistration(UserSignupStartRequest.builder() + .name(request.getName()) + .rpId(rpId) + .rpName(rpName) + .build() + ) ); } diff --git a/src/main/java/com/helioauth/passkeys/api/service/UserSignupService.java b/src/main/java/com/helioauth/passkeys/api/service/UserSignupService.java index 5c41ff3..a18ff8d 100644 --- a/src/main/java/com/helioauth/passkeys/api/service/UserSignupService.java +++ b/src/main/java/com/helioauth/passkeys/api/service/UserSignupService.java @@ -27,6 +27,7 @@ import com.helioauth.passkeys.api.mapper.UserCredentialMapper; import com.helioauth.passkeys.api.service.dto.CredentialRegistrationResult; import com.helioauth.passkeys.api.service.dto.RegistrationStartRequest; +import com.helioauth.passkeys.api.service.dto.UserSignupStartRequest; import com.helioauth.passkeys.api.service.exception.SignUpFailedException; import com.helioauth.passkeys.api.service.exception.UsernameAlreadyRegisteredException; import com.yubico.webauthn.data.ByteArray; @@ -51,11 +52,11 @@ public class UserSignupService { private final UserCredentialMapper userCredentialMapper; private final RegistrationResponseMapper registrationResponseMapper; - public SignUpStartResponse startRegistration(String name, String rpId) { - return startRegistration(name, rpId, null); - } + public SignUpStartResponse startRegistration(UserSignupStartRequest request) { + String name = request.getName(); + String rpId = request.getRpId(); + String rpName = request.getRpName(); - public SignUpStartResponse startRegistration(String name, String rpId, String rpName) { if (userRepository.findByName(name).isPresent()) { log.warn("Attempted to start registration for already existing username: {}", name); throw new UsernameAlreadyRegisteredException(); diff --git a/src/main/java/com/helioauth/passkeys/api/service/dto/UserSignupStartRequest.java b/src/main/java/com/helioauth/passkeys/api/service/dto/UserSignupStartRequest.java new file mode 100644 index 0000000..bd441ca --- /dev/null +++ b/src/main/java/com/helioauth/passkeys/api/service/dto/UserSignupStartRequest.java @@ -0,0 +1,49 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.helioauth.passkeys.api.service.dto; + +import lombok.Builder; +import lombok.Value; + +/** + * Data Transfer Object for starting a user signup process. + */ +@Value +@Builder +public class UserSignupStartRequest { + /** + * The username for the signup. + */ + String name; + + /** + * The Relying Party ID (typically the domain name). + */ + String rpId; + + /** + * The Relying Party name. Can be null. + */ + String rpName; + + /** + * Creates a builder with name and rpId set. + */ + public static UserSignupStartRequestBuilder withNameAndRpId(String name, String rpId) { + return builder().name(name).rpId(rpId); + } +} \ No newline at end of file From 457066ed51b901e60e5ddfddf3455dd3e24864f6 Mon Sep 17 00:00:00 2001 From: Victor Stanchev Date: Sun, 27 Apr 2025 08:12:23 +0300 Subject: [PATCH 21/21] tests: update startRegistration to use request objects --- .../api/service/UserSignupServiceTest.java | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/test/java/com/helioauth/passkeys/api/service/UserSignupServiceTest.java b/src/test/java/com/helioauth/passkeys/api/service/UserSignupServiceTest.java index 26ffe38..fd9c6bf 100644 --- a/src/test/java/com/helioauth/passkeys/api/service/UserSignupServiceTest.java +++ b/src/test/java/com/helioauth/passkeys/api/service/UserSignupServiceTest.java @@ -11,9 +11,10 @@ import com.helioauth.passkeys.api.mapper.UserCredentialMapper; import com.helioauth.passkeys.api.service.dto.AssertionStartResult; import com.helioauth.passkeys.api.service.dto.CredentialRegistrationResult; +import com.helioauth.passkeys.api.service.dto.RegistrationStartRequest; +import com.helioauth.passkeys.api.service.dto.UserSignupStartRequest; import com.helioauth.passkeys.api.service.exception.SignUpFailedException; import com.helioauth.passkeys.api.service.exception.UsernameAlreadyRegisteredException; -import com.yubico.webauthn.data.ByteArray; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mapstruct.factory.Mappers; @@ -31,7 +32,6 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -66,20 +66,22 @@ void testStartRegistration_Success() throws Exception { AssertionStartResult mockAssertionResult = new AssertionStartResult("requestId123", "{\"options\":\"value\"}"); SignUpStartResponse mockMappedResponse = new SignUpStartResponse("requestId123", "{\"options\":\"value\"}"); - when(webAuthnAuthenticator.startRegistration(eq(name), any(ByteArray.class), eq(rpId), eq(null))) + when(webAuthnAuthenticator.startRegistration(any(RegistrationStartRequest.class))) .thenReturn(mockAssertionResult); when(registrationResponseMapper.toSignUpStartResponse(mockAssertionResult)) .thenReturn(mockMappedResponse); // Act - SignUpStartResponse response = userSignupService.startRegistration(name, rpId); + SignUpStartResponse response = userSignupService.startRegistration( + UserSignupStartRequest.withNameAndRpId(name, rpId).build() + ); // Assert assertNotNull(response); assertEquals("requestId123", response.getRequestId()); assertEquals("{\"options\":\"value\"}", response.getOptions()); - verify(webAuthnAuthenticator, times(1)).startRegistration(eq(name), any(ByteArray.class), eq(rpId), eq(null)); + verify(webAuthnAuthenticator, times(1)).startRegistration(any(RegistrationStartRequest.class)); verify(registrationResponseMapper, times(1)).toSignUpStartResponse(mockAssertionResult); } @@ -88,11 +90,13 @@ void testStartRegistration_ThrowsSignUpFailedException() throws Exception { // Arrange String name = "testuser"; String rpId = "test-rp.com"; - when(webAuthnAuthenticator.startRegistration(eq(name), any(ByteArray.class), eq(rpId), eq(null))) + when(webAuthnAuthenticator.startRegistration(any(RegistrationStartRequest.class))) .thenThrow(JsonProcessingException.class); // Act & Assert - assertThrows(SignUpFailedException.class, () -> userSignupService.startRegistration(name, rpId)); + assertThrows(SignUpFailedException.class, () -> userSignupService.startRegistration( + UserSignupStartRequest.withNameAndRpId(name, rpId).build() + )); verify(registrationResponseMapper, never()).toSignUpStartResponse(any()); }