Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ce3fbc0
Add last modified date tracking to User entity
vstanchev Apr 13, 2025
026e90f
feat: add relying party name and hostname fields
vstanchev Apr 13, 2025
1c380b4
Remove unnecessary nullable annotations in User entity
vstanchev Apr 13, 2025
2ecd649
feat: Implemented reading relying party config from current client ap…
vstanchev Apr 14, 2025
6b15a7f
Get client application from security context
vstanchev Apr 18, 2025
7460380
Read origins from application for CORS
vstanchev Apr 18, 2025
1a0f02e
Fixed test
vstanchev Apr 18, 2025
0d58ad0
Added missing fields in application
vstanchev Apr 18, 2025
e5b0492
feat: Added hostname and origins to add and edit endpoints
vstanchev Apr 18, 2025
81e8123
test: Updated application service test
vstanchev Apr 18, 2025
bcfaebd
chore: Reordered ClientApplication properties
vstanchev Apr 18, 2025
6c92c96
chore: Updated gitignore
vstanchev Apr 18, 2025
96438f1
chore: Removed excess comments
vstanchev Apr 18, 2025
0b1b48c
feat: Revert allowed origins in apps
vstanchev Apr 19, 2025
05ced08
refactor: Use mapper when updating app
vstanchev Apr 19, 2025
0b5cb8e
feat: Get relying party name from application
vstanchev Apr 21, 2025
d090d94
test: Update test with relying party name
vstanchev Apr 21, 2025
e47c120
refactor: unify registration handling with DTO request objects
vstanchev Apr 27, 2025
01379ea
refactor: update tests to use RegistrationStartRequest object
vstanchev Apr 27, 2025
c0d1233
refactor: introduce UserSignupStartRequest DTO for signup flow
vstanchev Apr 27, 2025
457066e
tests: update startRegistration to use request objects
vstanchev Apr 27, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
HELP.md
TODO.md
target/
!.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
Expand Down Expand Up @@ -31,3 +32,4 @@ build/

### VS Code ###
.vscode/
.aider*
5 changes: 4 additions & 1 deletion docs/openapi/components/schemas/AddApplicationRequest.yaml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions docs/openapi/components/schemas/Application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
9 changes: 9 additions & 0 deletions docs/openapi/components/schemas/EditApplicationRequest.yaml
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion docs/openapi/paths/admin_v1_apps_id.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ put:
content:
application/json:
schema:
type: string
$ref: ../components/schemas/EditApplicationRequest.yaml
required: true
responses:
'200':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
import jakarta.servlet.http.HttpServletResponse;
import java.util.List;


/**
* @author Viktor Stanchev
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -39,9 +42,9 @@
@RequiredArgsConstructor
public class ClientApplicationController implements ApplicationsApi {

private final ClientApplicationService clientApplicationService;
private final ClientApplicationService clientApplicationService;

public ResponseEntity<List<Application>> listAll() {
public ResponseEntity<List<Application>> listAll() {
return ResponseEntity.ok(clientApplicationService.listAll());
}

Expand All @@ -58,14 +61,14 @@ public ResponseEntity<ApplicationApiKey> getApiKey(@PathVariable UUID id) {
}

public ResponseEntity<Application> 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<Application> edit(@PathVariable UUID id, @RequestBody String name) {
val updated = clientApplicationService.edit(id, name);
public ResponseEntity<Application> edit(@PathVariable UUID id, @RequestBody @Valid EditApplicationRequest request) {
val updated = clientApplicationService.edit(id, request);

return updated
.map(ResponseEntity::ok)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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<SignUpStartResponse> 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()
)
);
}

Expand Down Expand Up @@ -88,4 +111,4 @@ public ResponseEntity<SignInFinishResponse> finishSignInCredential(@RequestBody
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Sign in failed");
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
17 changes: 16 additions & 1 deletion src/main/java/com/helioauth/passkeys/api/domain/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -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<UserCredential> userCredentials;

@ManyToOne(targetEntity = ClientApplication.class, fetch = jakarta.persistence.FetchType.LAZY)
@JoinColumn(name = "application_id", nullable = true)
@JoinColumn(name = "application_id")
private ClientApplication clientApplication;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -33,4 +35,6 @@ public interface ClientApplicationMapper {
List<Application> toResponse(List<ClientApplication> clientApplication);

ApplicationApiKey toApiKeyResponse(ClientApplication clientApplication);

void updateClientApplication(@MappingTarget ClientApplication clientApplication, EditApplicationRequest request);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -59,22 +61,25 @@ public List<Application> 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<Application> edit(UUID id, String name) {
public Optional<Application> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -86,5 +89,5 @@ public ListPasskeysResponse getUserCredentials(UUID userUuid) {
List<UserCredential> userCredentials = userCredentialRepository.findAllByUserId(userUuid);
return new ListPasskeysResponse(userCredentialMapper.toDto(userCredentials));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
}
}
Expand Down
Loading