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* diff --git a/docs/openapi/components/schemas/AddApplicationRequest.yaml b/docs/openapi/components/schemas/AddApplicationRequest.yaml index f7a2ef2..039cc73 100644 --- a/docs/openapi/components/schemas/AddApplicationRequest.yaml +++ b/docs/openapi/components/schemas/AddApplicationRequest.yaml @@ -1,6 +1,9 @@ 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 \ No newline at end of file diff --git a/docs/openapi/components/schemas/Application.yaml b/docs/openapi/components/schemas/Application.yaml index 7ff60ce..abf6630 100644 --- a/docs/openapi/components/schemas/Application.yaml +++ b/docs/openapi/components/schemas/Application.yaml @@ -16,3 +16,6 @@ properties: type: string format: date-time description: Timestamp when the application was last updated. + relyingPartyHostname: + type: string + description: Hostname of the relying party. diff --git a/docs/openapi/components/schemas/EditApplicationRequest.yaml b/docs/openapi/components/schemas/EditApplicationRequest.yaml new file mode 100644 index 0000000..fee86b6 --- /dev/null +++ b/docs/openapi/components/schemas/EditApplicationRequest.yaml @@ -0,0 +1,9 @@ +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. \ 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/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/config/WebSecurityConfig.java b/src/main/java/com/helioauth/passkeys/api/config/WebSecurityConfig.java index 94cce3b..dc85ed5 100644 --- a/src/main/java/com/helioauth/passkeys/api/config/WebSecurityConfig.java +++ b/src/main/java/com/helioauth/passkeys/api/config/WebSecurityConfig.java @@ -39,6 +39,7 @@ import jakarta.servlet.http.HttpServletResponse; import java.util.List; + /** * @author Viktor Stanchev */ 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/controller/CredentialsController.java b/src/main/java/com/helioauth/passkeys/api/controller/CredentialsController.java index 3879c68..a7c6d20 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; @@ -29,11 +30,14 @@ 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; 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; @@ -46,16 +50,35 @@ */ @Slf4j @RestController -@CrossOrigin(origins = "*") @RequiredArgsConstructor +@CrossOrigin(origins = "*") public class CredentialsController implements SignUpApi, SignInApi { private final UserSignInService userSignInService; private final UserSignupService userSignupService; 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"); + } + + 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()); + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Client application configuration error: Missing RP hostname"); + } + return ResponseEntity.ok( - userSignupService.startRegistration(request.getName()) + userSignupService.startRegistration(UserSignupStartRequest.builder() + .name(request.getName()) + .rpId(rpId) + .rpName(rpName) + .build() + ) ); } @@ -88,4 +111,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/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) 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..ed10dc0 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,9 @@ 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; @@ -57,10 +62,20 @@ public class User { @Column private String displayName; + @CreatedDate + @Temporal(TemporalType.TIMESTAMP) + @Column(name = "created_at") + private Instant createdAt; + + @LastModifiedDate + @Temporal(TemporalType.TIMESTAMP) + @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; } 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 1c33b33..16ff1ba 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,25 @@ 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()) + .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 -> { + clientApplicationMapper.updateClientApplication(existing, request); + return clientApplicationMapper.toResponse(repository.save(existing)); + }); } @Transactional 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 ff10ccd..a18ff8d 100644 --- a/src/main/java/com/helioauth/passkeys/api/service/UserSignupService.java +++ b/src/main/java/com/helioauth/passkeys/api/service/UserSignupService.java @@ -26,8 +26,11 @@ 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.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 lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -49,13 +52,30 @@ public class UserSignupService { private final UserCredentialMapper userCredentialMapper; private final RegistrationResponseMapper registrationResponseMapper; - public SignUpStartResponse startRegistration(String name) { + public SignUpStartResponse startRegistration(UserSignupStartRequest request) { + String name = request.getName(); + String rpId = request.getRpId(); + String rpName = request.getRpName(); + + if (userRepository.findByName(name).isPresent()) { + log.warn("Attempted to start registration for already existing username: {}", name); + throw new UsernameAlreadyRegisteredException(); + } + try { + ByteArray userId = WebAuthnAuthenticator.generateRandom(); return registrationResponseMapper.toSignUpStartResponse( - webAuthnAuthenticator.startRegistration(name) + webAuthnAuthenticator.startRegistration( + RegistrationStartRequest.builder() + .name(name) + .userId(userId) + .rpHostname(rpId) + .rpName(rpName) + .build() + ) ); } catch (JsonProcessingException e) { - log.error("Register Credential failed", 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 14ef714..6c2f96b 100644 --- a/src/main/java/com/helioauth/passkeys/api/service/WebAuthnAuthenticator.java +++ b/src/main/java/com/helioauth/passkeys/api/service/WebAuthnAuthenticator.java @@ -18,13 +18,15 @@ 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.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; import com.yubico.webauthn.AssertionRequest; import com.yubico.webauthn.AssertionResult; import com.yubico.webauthn.FinishAssertionOptions; @@ -42,10 +44,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; 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; @@ -62,25 +64,26 @@ @RequiredArgsConstructor public class WebAuthnAuthenticator { - private final RelyingParty relyingParty; + 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(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) throws JsonProcessingException { - ByteArray id = generateRandom(); - return startRegistration(name, id); - } + log.debug("Starting registration for user '{}' with id '{}' for RP '{}' with name '{}'", name, userId.getBase64Url(), rpHostname, rpName); + + RelyingParty relyingParty = buildRelyingParty(rpHostname, rpName); - public AssertionStartResult startRegistration(String name, ByteArray userId) throws JsonProcessingException { ResidentKeyRequirement residentKeyRequirement = ResidentKeyRequirement.PREFERRED; - PublicKeyCredentialCreationOptions request = relyingParty.startRegistration(StartRegistrationOptions.builder() + PublicKeyCredentialCreationOptions creationOptions = relyingParty.startRegistration(StartRegistrationOptions.builder() .user( UserIdentity.builder() .name(name) @@ -97,9 +100,9 @@ public AssertionStartResult startRegistration(String name, ByteArray userId) thr ); 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 { @@ -122,9 +125,14 @@ public CredentialRegistrationResult finishRegistration(String requestId, String PublicKeyCredential pkc = PublicKeyCredential.parseRegistrationResponseJson(publicKeyCredentialJson); - try { - PublicKeyCredentialCreationOptions request = PublicKeyCredentialCreationOptions.fromJson(requestJson); + PublicKeyCredentialCreationOptions request = PublicKeyCredentialCreationOptions.fromJson(requestJson); + + String rpId = request.getRp().getId(); + log.debug("Finishing registration for request ID '{}' using RP ID '{}'", requestId, rpId); + RelyingParty relyingParty = buildRelyingParty(rpId); + + try { RegistrationResult result = relyingParty.finishRegistration(FinishRegistrationOptions.builder() .request(request) .response(pkc) @@ -132,12 +140,15 @@ 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); } } public AssertionStartResult startAssertion(String name) throws JsonProcessingException { + 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 +167,16 @@ public CredentialAssertionResult finishAssertion(String requestId, String public } webAuthnRequestCache.invalidate(requestId); + 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) .response(pkc) .build()); @@ -188,9 +202,30 @@ public CredentialAssertionResult finishAssertion(String requestId, String public throw new CredentialAssertionFailedException(); } - private static ByteArray generateRandom() { + private RelyingParty buildRelyingParty(String rpHostname) { + return buildRelyingParty(rpHostname, null); + } + + private RelyingParty buildRelyingParty(String rpHostname, String rpName) { + RelyingPartyIdentity rpIdentity = RelyingPartyIdentity.builder() + .id(rpHostname) + .name(rpName != null ? rpName : relyingPartyProperties.getDisplayName()) + .build(); + + return RelyingParty.builder() + .identity(rpIdentity) + .credentialRepository(databaseCredentialRepository) + .allowOriginPort(relyingPartyProperties.isAllowOriginPort()) + .build(); + } + + private RelyingParty buildDefaultRelyingParty() { + return buildRelyingParty(relyingPartyProperties.getHostname()); + } + + 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/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 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 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/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()); 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..b7aed2a 100644 --- a/src/test/java/com/helioauth/passkeys/api/service/UserCredentialManagerTest.java +++ b/src/test/java/com/helioauth/passkeys/api/service/UserCredentialManagerTest.java @@ -25,9 +25,11 @@ 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 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; @@ -71,6 +73,9 @@ class UserCredentialManagerTest { @Spy private UserCredentialMapper userCredentialMapper = Mappers.getMapper(UserCredentialMapper.class); + @Spy + private RegistrationResponseMapper registrationResponseMapper = Mappers.getMapper(RegistrationResponseMapper.class); + @InjectMocks private UserCredentialManager userCredentialManager; @@ -118,23 +123,24 @@ 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(any(RegistrationStartRequest.class))).thenReturn(startResult); // 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 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)); @@ -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..fd9c6bf 100644 --- a/src/test/java/com/helioauth/passkeys/api/service/UserSignupServiceTest.java +++ b/src/test/java/com/helioauth/passkeys/api/service/UserSignupServiceTest.java @@ -7,9 +7,12 @@ 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; 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 org.junit.jupiter.api.Test; @@ -27,8 +30,8 @@ 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; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -39,16 +42,19 @@ class UserSignupServiceTest { @Mock private WebAuthnAuthenticator webAuthnAuthenticator; - + @Mock private UserRepository userRepository; - + @Mock private UserCredentialRepository userCredentialRepository; - + @Spy private UserCredentialMapper userCredentialMapper = Mappers.getMapper(UserCredentialMapper.class); + @Mock + private RegistrationResponseMapper registrationResponseMapper; + @InjectMocks private UserSignupService userSignupService; @@ -56,27 +62,42 @@ 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"; + AssertionStartResult mockAssertionResult = new AssertionStartResult("requestId123", "{\"options\":\"value\"}"); + SignUpStartResponse mockMappedResponse = new SignUpStartResponse("requestId123", "{\"options\":\"value\"}"); + + when(webAuthnAuthenticator.startRegistration(any(RegistrationStartRequest.class))) + .thenReturn(mockAssertionResult); + when(registrationResponseMapper.toSignUpStartResponse(mockAssertionResult)) + .thenReturn(mockMappedResponse); + // Act - SignUpStartResponse response = userSignupService.startRegistration(name); + 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(name); + verify(webAuthnAuthenticator, times(1)).startRegistration(any(RegistrationStartRequest.class)); + 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"; + when(webAuthnAuthenticator.startRegistration(any(RegistrationStartRequest.class))) + .thenThrow(JsonProcessingException.class); // Act & Assert - assertThrows(SignUpFailedException.class, () -> userSignupService.startRegistration(name)); + assertThrows(SignUpFailedException.class, () -> userSignupService.startRegistration( + UserSignupStartRequest.withNameAndRpId(name, rpId).build() + )); + verify(registrationResponseMapper, never()).toSignUpStartResponse(any()); } @Test @@ -93,7 +114,7 @@ void testFinishRegistration_Success() throws Exception { "", "","", true, true, true ); - + User mockUser = User.builder() .id(UUID.randomUUID()) .name(username) @@ -114,6 +135,7 @@ void testFinishRegistration_Success() throws Exception { assertNotNull(response); assertEquals(requestId, response.getRequestId()); assertNotNull(response.getUserId()); + verify(userCredentialMapper, times(1)).fromCredentialRegistrationResult(mockResult); } @Test @@ -138,7 +160,7 @@ 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\"}"; @@ -150,8 +172,29 @@ void testFinishRegistration_ThrowsSignUpFailedException() throws Exception { () -> userSignupService.finishRegistration(requestId, publicKeyCredentialJson) ); + 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)); } -} \ 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()); + when(webAuthnAuthenticator.finishRegistration(requestId, publicKeyCredentialJson)).thenThrow(IOException.class); + + // Act & Assert + assertThrows(SignUpFailedException.class, + () -> userSignupService.finishRegistration(requestId, publicKeyCredentialJson) + ); + + 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 f3ea2b8..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,20 +18,21 @@ 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.FinishRegistrationOptions; +import com.helioauth.passkeys.api.webauthn.DatabaseCredentialRepository; import com.yubico.webauthn.RegistrationResult; -import com.yubico.webauthn.RelyingParty; -import com.yubico.webauthn.StartRegistrationOptions; +import com.yubico.webauthn.data.AuthenticatorAttestationResponse; import com.yubico.webauthn.data.ByteArray; -import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions; -import com.yubico.webauthn.data.RelyingPartyIdentity; import com.yubico.webauthn.data.UserIdentity; import com.yubico.webauthn.data.exception.HexException; import com.yubico.webauthn.exception.RegistrationFailedException; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mapstruct.factory.Mappers; @@ -41,13 +42,15 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.io.IOException; -import java.util.List; +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 static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.anyString; -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; @@ -57,6 +60,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_HOSTNAME = "localhost"; + 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) @@ -99,88 +104,81 @@ public class WebAuthnAuthenticatorTest { }"""; @Mock - private RelyingParty relyingParty; + private Cache webAuthnRequestCache; + + @Spy + private WebAuthnRelyingPartyProperties relyingPartyProperties = new WebAuthnRelyingPartyProperties(); @Mock - private Cache webAuthnRequestCache; + private DatabaseCredentialRepository databaseCredentialRepository; @Spy private CredentialRegistrationResultMapper credentialRegistrationResultMapper = Mappers.getMapper(CredentialRegistrationResultMapper.class); + @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() - ); + + @BeforeEach + void setUp() { + relyingPartyProperties.setHostname(TEST_RP_HOSTNAME); + 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); - - // Execute the test - AssertionStartResult response = authenticator.startRegistration(TEST_USER_NAME, TEST_USER_ID); + AssertionStartResult response = authenticator.startRegistration( + RegistrationStartRequest.builder() + .name(TEST_USER_NAME) + .userId(TEST_USER_ID) + .rpHostname(TEST_RP_HOSTNAME) + .build() + ); - // 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_HOSTNAME + "\"")); + 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); + AssertionStartResult response = authenticator.startRegistration(RegistrationStartRequest.withName(TEST_USER_NAME).build()); - // 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_HOSTNAME + "\"")); + assertTrue(response.options().contains("\"name\":\"" + TEST_RP_NAME + "\"")); } @Test public void testFinishRegistrationSuccess() throws IOException, RegistrationFailedException { String requestId = "requestId"; - RegistrationResult mockResult = mock( RegistrationResult.class); + CredentialRegistrationResult mappedResult = new CredentialRegistrationResult("name", "displayName", "credentialId", "userHandle", 1L, "publicKeyCose", + "attestationObject", "clientDataJson", true, true, true); + + doReturn(mappedResult).when(credentialRegistrationResultMapper) + .fromRegistrationResult( + any(RegistrationResult.class), + any(UserIdentity.class), + any(AuthenticatorAttestationResponse.class) + ); 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)); CredentialRegistrationResult result = authenticator.finishRegistration(requestId, AUTHENTICATOR_RESPONSE_JSON); assertNotNull(result); - assertNotNull(result.name()); + assertEquals(mappedResult.name(), result.name()); verify(webAuthnRequestCache, times(1)).invalidate(requestId); } @@ -202,17 +200,24 @@ 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())); + + doThrow(new RuntimeException("Simulated mapping error")).when(credentialRegistrationResultMapper) + .fromRegistrationResult( + any(RegistrationResult.class), + any(UserIdentity.class), + any(AuthenticatorAttestationResponse.class) // Use specific type + ); 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 +}