From 3ac0d9c6e446e735dab045d046d9d1732c25f89e Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 26 May 2026 12:10:00 +0000
Subject: [PATCH 1/2] Initial plan
From 68a8bbd5bb88793bc105d11d756c4b64ec425210 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 26 May 2026 12:35:54 +0000
Subject: [PATCH 2/2] feat: add passkey authentication support with webauthn
Agent-Logs-Url: https://github.com/vitorhugo-java/SpringBoot-JobApplyTracker/sessions/501d1fef-d9d5-4685-ba83-47e5829c4739
Co-authored-by: abigaildotnet <142514517+abigaildotnet@users.noreply.github.com>
---
pom.xml | 6 +
.../com/jobtracker/config/SecurityConfig.java | 2 +
.../com/jobtracker/config/WebAuthnConfig.java | 29 ++
.../jobtracker/config/WebAuthnProperties.java | 14 +
.../jobtracker/controller/AuthController.java | 33 +-
.../dto/auth/PasskeyLoginOptionsRequest.java | 9 +
.../dto/auth/PasskeyOptionsResponse.java | 12 +
.../dto/auth/PasskeyStatusResponse.java | 6 +
.../dto/auth/PasskeyVerifyRequest.java | 12 +
.../jobtracker/entity/WebAuthnChallenge.java | 131 ++++++++
.../jobtracker/entity/WebAuthnCredential.java | 129 +++++++
.../entity/enums/WebAuthnChallengeType.java | 6 +
.../WebAuthnChallengeRepository.java | 14 +
.../WebAuthnCredentialRepository.java | 15 +
.../com/jobtracker/service/AuthService.java | 5 +
.../JpaWebAuthnCredentialRepository.java | 140 ++++++++
.../service/PasskeyAuthService.java | 318 ++++++++++++++++++
src/main/resources/application.yml | 8 +
.../migration/V16__add_webauthn_passkeys.sql | 30 ++
.../integration/AuthControllerIT.java | 46 +++
src/test/resources/application-test.yml | 7 +
21 files changed, 971 insertions(+), 1 deletion(-)
create mode 100644 src/main/java/com/jobtracker/config/WebAuthnConfig.java
create mode 100644 src/main/java/com/jobtracker/config/WebAuthnProperties.java
create mode 100644 src/main/java/com/jobtracker/dto/auth/PasskeyLoginOptionsRequest.java
create mode 100644 src/main/java/com/jobtracker/dto/auth/PasskeyOptionsResponse.java
create mode 100644 src/main/java/com/jobtracker/dto/auth/PasskeyStatusResponse.java
create mode 100644 src/main/java/com/jobtracker/dto/auth/PasskeyVerifyRequest.java
create mode 100644 src/main/java/com/jobtracker/entity/WebAuthnChallenge.java
create mode 100644 src/main/java/com/jobtracker/entity/WebAuthnCredential.java
create mode 100644 src/main/java/com/jobtracker/entity/enums/WebAuthnChallengeType.java
create mode 100644 src/main/java/com/jobtracker/repository/WebAuthnChallengeRepository.java
create mode 100644 src/main/java/com/jobtracker/repository/WebAuthnCredentialRepository.java
create mode 100644 src/main/java/com/jobtracker/service/JpaWebAuthnCredentialRepository.java
create mode 100644 src/main/java/com/jobtracker/service/PasskeyAuthService.java
create mode 100644 src/main/resources/db/migration/V16__add_webauthn_passkeys.sql
diff --git a/pom.xml b/pom.xml
index 0f5b43be..16b9623d 100644
--- a/pom.xml
+++ b/pom.xml
@@ -23,6 +23,7 @@
1.19.8
5.4.0
0.10.2
+ 2.7.0
@@ -152,6 +153,11 @@
commons-codec
commons-codec
+
+ com.yubico
+ webauthn-server-core
+ ${webauthn-server.version}
+
diff --git a/src/main/java/com/jobtracker/config/SecurityConfig.java b/src/main/java/com/jobtracker/config/SecurityConfig.java
index b87af4b3..cc22c810 100644
--- a/src/main/java/com/jobtracker/config/SecurityConfig.java
+++ b/src/main/java/com/jobtracker/config/SecurityConfig.java
@@ -43,6 +43,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.requestMatchers(
"/api/v1/auth/register",
"/api/v1/auth/login",
+ "/api/v1/auth/passkey/login/options",
+ "/api/v1/auth/passkey/login/verify",
"/api/v1/auth/refresh",
"/api/v1/auth/forgot-password",
"/api/v1/auth/reset-password",
diff --git a/src/main/java/com/jobtracker/config/WebAuthnConfig.java b/src/main/java/com/jobtracker/config/WebAuthnConfig.java
new file mode 100644
index 00000000..d91af088
--- /dev/null
+++ b/src/main/java/com/jobtracker/config/WebAuthnConfig.java
@@ -0,0 +1,29 @@
+package com.jobtracker.config;
+
+import com.yubico.webauthn.CredentialRepository;
+import com.yubico.webauthn.RelyingParty;
+import com.yubico.webauthn.data.RelyingPartyIdentity;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.util.Set;
+
+@Configuration
+@EnableConfigurationProperties(WebAuthnProperties.class)
+public class WebAuthnConfig {
+
+ @Bean
+ public RelyingParty relyingParty(WebAuthnProperties properties, CredentialRepository credentialRepository) {
+ RelyingPartyIdentity identity = RelyingPartyIdentity.builder()
+ .id(properties.rpId())
+ .name(properties.rpName())
+ .build();
+
+ return RelyingParty.builder()
+ .identity(identity)
+ .credentialRepository(credentialRepository)
+ .origins(Set.copyOf(properties.origins()))
+ .build();
+ }
+}
diff --git a/src/main/java/com/jobtracker/config/WebAuthnProperties.java b/src/main/java/com/jobtracker/config/WebAuthnProperties.java
new file mode 100644
index 00000000..e3c3f88a
--- /dev/null
+++ b/src/main/java/com/jobtracker/config/WebAuthnProperties.java
@@ -0,0 +1,14 @@
+package com.jobtracker.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+import java.util.List;
+
+@ConfigurationProperties(prefix = "app.webauthn")
+public record WebAuthnProperties(
+ String rpId,
+ String rpName,
+ List origins,
+ long challengeTimeoutSeconds
+) {
+}
diff --git a/src/main/java/com/jobtracker/controller/AuthController.java b/src/main/java/com/jobtracker/controller/AuthController.java
index ba030429..e6b0d95e 100644
--- a/src/main/java/com/jobtracker/controller/AuthController.java
+++ b/src/main/java/com/jobtracker/controller/AuthController.java
@@ -3,6 +3,7 @@
import com.jobtracker.dto.auth.*;
import com.jobtracker.mapper.AuthMapper;
import com.jobtracker.service.AuthService;
+import com.jobtracker.service.PasskeyAuthService;
import com.jobtracker.util.SecurityUtils;
import io.github.resilience4j.ratelimiter.annotation.RateLimiter;
import io.swagger.v3.oas.annotations.Operation;
@@ -22,13 +23,15 @@
public class AuthController {
private final AuthService authService;
+ private final PasskeyAuthService passkeyAuthService;
private final AuthMapper authMapper;
private final SecurityUtils securityUtils;
private static final String REFRESH_COOKIE_PATH = "/api/v1/auth/refresh";
- public AuthController(AuthService authService, AuthMapper authMapper, SecurityUtils securityUtils) {
+ public AuthController(AuthService authService, PasskeyAuthService passkeyAuthService, AuthMapper authMapper, SecurityUtils securityUtils) {
this.authService = authService;
+ this.passkeyAuthService = passkeyAuthService;
this.authMapper = authMapper;
this.securityUtils = securityUtils;
}
@@ -191,4 +194,32 @@ public ResponseEntity changePassword(@Valid @RequestBody Change
return ResponseEntity.ok(authService.changePassword(request));
}
+ @PostMapping("/passkey/register/options")
+ public ResponseEntity passkeyRegisterOptions() {
+ return ResponseEntity.ok(passkeyAuthService.registerOptions());
+ }
+
+ @PostMapping("/passkey/register/verify")
+ public ResponseEntity passkeyRegisterVerify(@Valid @RequestBody PasskeyVerifyRequest request) {
+ return ResponseEntity.ok(passkeyAuthService.registerVerify(request));
+ }
+
+ @PostMapping("/passkey/login/options")
+ public ResponseEntity passkeyLoginOptions(@Valid @RequestBody PasskeyLoginOptionsRequest request) {
+ return ResponseEntity.ok(passkeyAuthService.loginOptions(request));
+ }
+
+ @PostMapping("/passkey/login/verify")
+ public ResponseEntity passkeyLoginVerify(@Valid @RequestBody PasskeyVerifyRequest request,
+ HttpServletResponse response) {
+ AuthResponse authResponse = passkeyAuthService.loginVerify(request);
+ setRefreshTokenCookie(response, authService.getLastRefreshToken());
+ return ResponseEntity.ok(authResponse);
+ }
+
+ @GetMapping("/passkey/me")
+ public ResponseEntity passkeyMe() {
+ return ResponseEntity.ok(passkeyAuthService.me());
+ }
+
}
diff --git a/src/main/java/com/jobtracker/dto/auth/PasskeyLoginOptionsRequest.java b/src/main/java/com/jobtracker/dto/auth/PasskeyLoginOptionsRequest.java
new file mode 100644
index 00000000..5663b47f
--- /dev/null
+++ b/src/main/java/com/jobtracker/dto/auth/PasskeyLoginOptionsRequest.java
@@ -0,0 +1,9 @@
+package com.jobtracker.dto.auth;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+
+public record PasskeyLoginOptionsRequest(
+ @NotBlank @Email String email
+) {
+}
diff --git a/src/main/java/com/jobtracker/dto/auth/PasskeyOptionsResponse.java b/src/main/java/com/jobtracker/dto/auth/PasskeyOptionsResponse.java
new file mode 100644
index 00000000..3517a037
--- /dev/null
+++ b/src/main/java/com/jobtracker/dto/auth/PasskeyOptionsResponse.java
@@ -0,0 +1,12 @@
+package com.jobtracker.dto.auth;
+
+import com.fasterxml.jackson.databind.JsonNode;
+
+import java.util.UUID;
+
+public record PasskeyOptionsResponse(
+ boolean passkeyAvailable,
+ UUID challengeId,
+ JsonNode publicKey
+) {
+}
diff --git a/src/main/java/com/jobtracker/dto/auth/PasskeyStatusResponse.java b/src/main/java/com/jobtracker/dto/auth/PasskeyStatusResponse.java
new file mode 100644
index 00000000..40f5e867
--- /dev/null
+++ b/src/main/java/com/jobtracker/dto/auth/PasskeyStatusResponse.java
@@ -0,0 +1,6 @@
+package com.jobtracker.dto.auth;
+
+public record PasskeyStatusResponse(
+ boolean hasPasskeys
+) {
+}
diff --git a/src/main/java/com/jobtracker/dto/auth/PasskeyVerifyRequest.java b/src/main/java/com/jobtracker/dto/auth/PasskeyVerifyRequest.java
new file mode 100644
index 00000000..93caaa41
--- /dev/null
+++ b/src/main/java/com/jobtracker/dto/auth/PasskeyVerifyRequest.java
@@ -0,0 +1,12 @@
+package com.jobtracker.dto.auth;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import jakarta.validation.constraints.NotNull;
+
+import java.util.UUID;
+
+public record PasskeyVerifyRequest(
+ @NotNull UUID challengeId,
+ @NotNull JsonNode credential
+) {
+}
diff --git a/src/main/java/com/jobtracker/entity/WebAuthnChallenge.java b/src/main/java/com/jobtracker/entity/WebAuthnChallenge.java
new file mode 100644
index 00000000..7e7c0185
--- /dev/null
+++ b/src/main/java/com/jobtracker/entity/WebAuthnChallenge.java
@@ -0,0 +1,131 @@
+package com.jobtracker.entity;
+
+import com.jobtracker.entity.enums.WebAuthnChallengeType;
+import jakarta.persistence.*;
+import org.hibernate.annotations.UuidGenerator;
+
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+@Entity
+@Table(name = "webauthn_challenges", indexes = {
+ @Index(name = "idx_webauthn_challenges_user_id", columnList = "user_id"),
+ @Index(name = "idx_webauthn_challenges_expires_at", columnList = "expires_at")
+})
+public class WebAuthnChallenge {
+
+ @Id
+ @UuidGenerator(style = UuidGenerator.Style.TIME)
+ @Column(name = "id", columnDefinition = "BINARY(16)", updatable = false, nullable = false)
+ private UUID id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id")
+ private User user;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "type", nullable = false, length = 32)
+ private WebAuthnChallengeType type;
+
+ @Lob
+ @Column(name = "request_json", nullable = false)
+ private String requestJson;
+
+ @Column(name = "challenge", nullable = false, length = 512)
+ private String challenge;
+
+ @Column(name = "used", nullable = false)
+ private boolean used;
+
+ @Column(name = "expires_at", nullable = false)
+ private LocalDateTime expiresAt;
+
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private LocalDateTime createdAt;
+
+ @Column(name = "updated_at", nullable = false)
+ private LocalDateTime updatedAt;
+
+ @PrePersist
+ protected void onCreate() {
+ createdAt = LocalDateTime.now();
+ updatedAt = LocalDateTime.now();
+ }
+
+ @PreUpdate
+ protected void onUpdate() {
+ updatedAt = LocalDateTime.now();
+ }
+
+ public UUID getId() {
+ return id;
+ }
+
+ public void setId(UUID id) {
+ this.id = id;
+ }
+
+ public User getUser() {
+ return user;
+ }
+
+ public void setUser(User user) {
+ this.user = user;
+ }
+
+ public WebAuthnChallengeType getType() {
+ return type;
+ }
+
+ public void setType(WebAuthnChallengeType type) {
+ this.type = type;
+ }
+
+ public String getRequestJson() {
+ return requestJson;
+ }
+
+ public void setRequestJson(String requestJson) {
+ this.requestJson = requestJson;
+ }
+
+ public String getChallenge() {
+ return challenge;
+ }
+
+ public void setChallenge(String challenge) {
+ this.challenge = challenge;
+ }
+
+ public boolean isUsed() {
+ return used;
+ }
+
+ public void setUsed(boolean used) {
+ this.used = used;
+ }
+
+ public LocalDateTime getExpiresAt() {
+ return expiresAt;
+ }
+
+ public void setExpiresAt(LocalDateTime expiresAt) {
+ this.expiresAt = expiresAt;
+ }
+
+ public LocalDateTime getCreatedAt() {
+ return createdAt;
+ }
+
+ public void setCreatedAt(LocalDateTime createdAt) {
+ this.createdAt = createdAt;
+ }
+
+ public LocalDateTime getUpdatedAt() {
+ return updatedAt;
+ }
+
+ public void setUpdatedAt(LocalDateTime updatedAt) {
+ this.updatedAt = updatedAt;
+ }
+}
diff --git a/src/main/java/com/jobtracker/entity/WebAuthnCredential.java b/src/main/java/com/jobtracker/entity/WebAuthnCredential.java
new file mode 100644
index 00000000..52b322a1
--- /dev/null
+++ b/src/main/java/com/jobtracker/entity/WebAuthnCredential.java
@@ -0,0 +1,129 @@
+package com.jobtracker.entity;
+
+import jakarta.persistence.*;
+import org.hibernate.annotations.UuidGenerator;
+
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+@Entity
+@Table(name = "webauthn_credentials", indexes = {
+ @Index(name = "idx_webauthn_credentials_user_id", columnList = "user_id"),
+ @Index(name = "idx_webauthn_credentials_credential_id", columnList = "credential_id", unique = true)
+})
+public class WebAuthnCredential {
+
+ @Id
+ @UuidGenerator(style = UuidGenerator.Style.TIME)
+ @Column(name = "id", columnDefinition = "BINARY(16)", updatable = false, nullable = false)
+ private UUID id;
+
+ @ManyToOne(fetch = FetchType.LAZY, optional = false)
+ @JoinColumn(name = "user_id", nullable = false)
+ private User user;
+
+ @Column(name = "credential_id", nullable = false, unique = true, length = 512)
+ private String credentialId;
+
+ @Lob
+ @Column(name = "public_key_cose", nullable = false)
+ private String publicKeyCose;
+
+ @Column(name = "sign_count", nullable = false)
+ private long signCount;
+
+ @Column(name = "transports", length = 255)
+ private String transports;
+
+ @Column(name = "user_handle", nullable = false, length = 255)
+ private String userHandle;
+
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private LocalDateTime createdAt;
+
+ @Column(name = "updated_at", nullable = false)
+ private LocalDateTime updatedAt;
+
+ @PrePersist
+ protected void onCreate() {
+ createdAt = LocalDateTime.now();
+ updatedAt = LocalDateTime.now();
+ }
+
+ @PreUpdate
+ protected void onUpdate() {
+ updatedAt = LocalDateTime.now();
+ }
+
+ public UUID getId() {
+ return id;
+ }
+
+ public void setId(UUID id) {
+ this.id = id;
+ }
+
+ public User getUser() {
+ return user;
+ }
+
+ public void setUser(User user) {
+ this.user = user;
+ }
+
+ public String getCredentialId() {
+ return credentialId;
+ }
+
+ public void setCredentialId(String credentialId) {
+ this.credentialId = credentialId;
+ }
+
+ public String getPublicKeyCose() {
+ return publicKeyCose;
+ }
+
+ public void setPublicKeyCose(String publicKeyCose) {
+ this.publicKeyCose = publicKeyCose;
+ }
+
+ public long getSignCount() {
+ return signCount;
+ }
+
+ public void setSignCount(long signCount) {
+ this.signCount = signCount;
+ }
+
+ public String getTransports() {
+ return transports;
+ }
+
+ public void setTransports(String transports) {
+ this.transports = transports;
+ }
+
+ public String getUserHandle() {
+ return userHandle;
+ }
+
+ public void setUserHandle(String userHandle) {
+ this.userHandle = userHandle;
+ }
+
+ public LocalDateTime getCreatedAt() {
+ return createdAt;
+ }
+
+ public void setCreatedAt(LocalDateTime createdAt) {
+ this.createdAt = createdAt;
+ }
+
+ public LocalDateTime getUpdatedAt() {
+ return updatedAt;
+ }
+
+ public void setUpdatedAt(LocalDateTime updatedAt) {
+ this.updatedAt = updatedAt;
+ }
+}
diff --git a/src/main/java/com/jobtracker/entity/enums/WebAuthnChallengeType.java b/src/main/java/com/jobtracker/entity/enums/WebAuthnChallengeType.java
new file mode 100644
index 00000000..fb6005ed
--- /dev/null
+++ b/src/main/java/com/jobtracker/entity/enums/WebAuthnChallengeType.java
@@ -0,0 +1,6 @@
+package com.jobtracker.entity.enums;
+
+public enum WebAuthnChallengeType {
+ REGISTRATION,
+ AUTHENTICATION
+}
diff --git a/src/main/java/com/jobtracker/repository/WebAuthnChallengeRepository.java b/src/main/java/com/jobtracker/repository/WebAuthnChallengeRepository.java
new file mode 100644
index 00000000..7dd54e92
--- /dev/null
+++ b/src/main/java/com/jobtracker/repository/WebAuthnChallengeRepository.java
@@ -0,0 +1,14 @@
+package com.jobtracker.repository;
+
+import com.jobtracker.entity.WebAuthnChallenge;
+import com.jobtracker.entity.enums.WebAuthnChallengeType;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.time.LocalDateTime;
+import java.util.Optional;
+import java.util.UUID;
+
+public interface WebAuthnChallengeRepository extends JpaRepository {
+ Optional findByIdAndTypeAndUsedFalse(UUID id, WebAuthnChallengeType type);
+ void deleteAllByExpiresAtBefore(LocalDateTime threshold);
+}
diff --git a/src/main/java/com/jobtracker/repository/WebAuthnCredentialRepository.java b/src/main/java/com/jobtracker/repository/WebAuthnCredentialRepository.java
new file mode 100644
index 00000000..8c1f9898
--- /dev/null
+++ b/src/main/java/com/jobtracker/repository/WebAuthnCredentialRepository.java
@@ -0,0 +1,15 @@
+package com.jobtracker.repository;
+
+import com.jobtracker.entity.User;
+import com.jobtracker.entity.WebAuthnCredential;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+public interface WebAuthnCredentialRepository extends JpaRepository {
+ List findByUser(User user);
+ long countByUser(User user);
+ Optional findByCredentialId(String credentialId);
+}
diff --git a/src/main/java/com/jobtracker/service/AuthService.java b/src/main/java/com/jobtracker/service/AuthService.java
index ba7c4f41..231c4e51 100644
--- a/src/main/java/com/jobtracker/service/AuthService.java
+++ b/src/main/java/com/jobtracker/service/AuthService.java
@@ -125,6 +125,11 @@ public AuthResponse login(LoginRequest request) {
}
}
+ @Transactional
+ public AuthResponse issueAuthTokens(User user) {
+ return buildAuthResponse(user);
+ }
+
@Transactional
public RefreshResponse refresh(RefreshTokenRequest request, String refreshToken) {
Span span = tracer.nextSpan().name("token-refresh").start();
diff --git a/src/main/java/com/jobtracker/service/JpaWebAuthnCredentialRepository.java b/src/main/java/com/jobtracker/service/JpaWebAuthnCredentialRepository.java
new file mode 100644
index 00000000..3a855a32
--- /dev/null
+++ b/src/main/java/com/jobtracker/service/JpaWebAuthnCredentialRepository.java
@@ -0,0 +1,140 @@
+package com.jobtracker.service;
+
+import com.jobtracker.entity.User;
+import com.jobtracker.entity.WebAuthnCredential;
+import com.jobtracker.repository.UserRepository;
+import com.jobtracker.repository.WebAuthnCredentialRepository;
+import com.yubico.webauthn.CredentialRepository;
+import com.yubico.webauthn.RegisteredCredential;
+import com.yubico.webauthn.data.AuthenticatorTransport;
+import com.yubico.webauthn.data.ByteArray;
+import com.yubico.webauthn.data.PublicKeyCredentialDescriptor;
+import com.yubico.webauthn.data.exception.Base64UrlException;
+import org.springframework.stereotype.Component;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Optional;
+import java.util.Set;
+import java.util.SortedSet;
+import java.util.TreeSet;
+import java.util.stream.Collectors;
+
+@Component
+public class JpaWebAuthnCredentialRepository implements CredentialRepository {
+
+ private final WebAuthnCredentialRepository webAuthnCredentialRepository;
+ private final UserRepository userRepository;
+
+ public JpaWebAuthnCredentialRepository(WebAuthnCredentialRepository webAuthnCredentialRepository,
+ UserRepository userRepository) {
+ this.webAuthnCredentialRepository = webAuthnCredentialRepository;
+ this.userRepository = userRepository;
+ }
+
+ @Override
+ public Set getCredentialIdsForUsername(String username) {
+ return userRepository.findByEmail(username)
+ .map(user -> webAuthnCredentialRepository.findByUser(user).stream()
+ .map(this::toDescriptor)
+ .collect(Collectors.toSet()))
+ .orElseGet(Collections::emptySet);
+ }
+
+ @Override
+ public Optional getUserHandleForUsername(String username) {
+ return userRepository.findByEmail(username)
+ .map(User::getId)
+ .map(this::uuidToByteArray);
+ }
+
+ @Override
+ public Optional getUsernameForUserHandle(ByteArray userHandle) {
+ return userRepository.findById(byteArrayToUuid(userHandle))
+ .map(User::getEmail);
+ }
+
+ @Override
+ public Optional lookup(ByteArray credentialId, ByteArray userHandle) {
+ return webAuthnCredentialRepository.findByCredentialId(credentialId.getBase64Url())
+ .filter(stored -> stored.getUserHandle().equals(userHandle.getBase64Url()))
+ .map(this::toRegisteredCredential);
+ }
+
+ @Override
+ public Set lookupAll(ByteArray credentialId) {
+ return webAuthnCredentialRepository.findByCredentialId(credentialId.getBase64Url())
+ .map(stored -> Set.of(toRegisteredCredential(stored)))
+ .orElseGet(Collections::emptySet);
+ }
+
+ private PublicKeyCredentialDescriptor toDescriptor(WebAuthnCredential credential) {
+ PublicKeyCredentialDescriptor.PublicKeyCredentialDescriptorBuilder builder = PublicKeyCredentialDescriptor.builder()
+ .id(parseBase64Url(credential.getCredentialId()));
+ parseTransports(credential.getTransports()).ifPresent(builder::transports);
+ return builder.build();
+ }
+
+ private Optional> parseTransports(String transports) {
+ if (transports == null || transports.isBlank()) {
+ return Optional.empty();
+ }
+ SortedSet parsed = Arrays.stream(transports.split(","))
+ .map(String::trim)
+ .filter(value -> !value.isEmpty())
+ .map(this::toTransportOrNull)
+ .filter(value -> value != null)
+ .collect(Collectors.toCollection(TreeSet::new));
+ return parsed.isEmpty() ? Optional.empty() : Optional.of(parsed);
+ }
+
+ private AuthenticatorTransport toTransportOrNull(String value) {
+ try {
+ return AuthenticatorTransport.of(value);
+ } catch (IllegalArgumentException ignored) {
+ return null;
+ }
+ }
+
+ private RegisteredCredential toRegisteredCredential(WebAuthnCredential credential) {
+ return RegisteredCredential.builder()
+ .credentialId(parseBase64Url(credential.getCredentialId()))
+ .userHandle(parseBase64Url(credential.getUserHandle()))
+ .publicKeyCose(parseBase64Url(credential.getPublicKeyCose()))
+ .signatureCount(credential.getSignCount())
+ .build();
+ }
+
+ private ByteArray parseBase64Url(String value) {
+ try {
+ return ByteArray.fromBase64Url(value);
+ } catch (Base64UrlException e) {
+ throw new IllegalArgumentException("Invalid stored WebAuthn credential encoding", e);
+ }
+ }
+
+ private ByteArray uuidToByteArray(java.util.UUID uuid) {
+ byte[] bytes = new byte[16];
+ long mostSignificant = uuid.getMostSignificantBits();
+ long leastSignificant = uuid.getLeastSignificantBits();
+ for (int i = 0; i < 8; i++) {
+ bytes[i] = (byte) (mostSignificant >>> (56 - (i * 8)));
+ bytes[8 + i] = (byte) (leastSignificant >>> (56 - (i * 8)));
+ }
+ return new ByteArray(bytes);
+ }
+
+ private java.util.UUID byteArrayToUuid(ByteArray userHandle) {
+ byte[] bytes = userHandle.getBytes();
+ if (bytes.length != 16) {
+ throw new IllegalArgumentException("Invalid user handle length");
+ }
+ long mostSignificant = 0;
+ long leastSignificant = 0;
+ for (int i = 0; i < 8; i++) {
+ mostSignificant = (mostSignificant << 8) | (bytes[i] & 0xff);
+ leastSignificant = (leastSignificant << 8) | (bytes[8 + i] & 0xff);
+ }
+ return new java.util.UUID(mostSignificant, leastSignificant);
+ }
+}
diff --git a/src/main/java/com/jobtracker/service/PasskeyAuthService.java b/src/main/java/com/jobtracker/service/PasskeyAuthService.java
new file mode 100644
index 00000000..67411d77
--- /dev/null
+++ b/src/main/java/com/jobtracker/service/PasskeyAuthService.java
@@ -0,0 +1,318 @@
+package com.jobtracker.service;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.jobtracker.config.WebAuthnProperties;
+import com.jobtracker.dto.auth.AuthResponse;
+import com.jobtracker.dto.auth.MessageResponse;
+import com.jobtracker.dto.auth.PasskeyLoginOptionsRequest;
+import com.jobtracker.dto.auth.PasskeyOptionsResponse;
+import com.jobtracker.dto.auth.PasskeyStatusResponse;
+import com.jobtracker.dto.auth.PasskeyVerifyRequest;
+import com.jobtracker.entity.User;
+import com.jobtracker.entity.WebAuthnChallenge;
+import com.jobtracker.entity.WebAuthnCredential;
+import com.jobtracker.entity.enums.WebAuthnChallengeType;
+import com.jobtracker.exception.BadRequestException;
+import com.jobtracker.exception.UnauthorizedException;
+import com.jobtracker.repository.UserRepository;
+import com.jobtracker.repository.WebAuthnChallengeRepository;
+import com.jobtracker.repository.WebAuthnCredentialRepository;
+import com.jobtracker.util.SecurityUtils;
+import com.yubico.webauthn.AssertionRequest;
+import com.yubico.webauthn.AssertionResult;
+import com.yubico.webauthn.FinishAssertionOptions;
+import com.yubico.webauthn.FinishRegistrationOptions;
+import com.yubico.webauthn.RegistrationResult;
+import com.yubico.webauthn.RelyingParty;
+import com.yubico.webauthn.StartAssertionOptions;
+import com.yubico.webauthn.StartRegistrationOptions;
+import com.yubico.webauthn.data.AuthenticatorAssertionResponse;
+import com.yubico.webauthn.data.AuthenticatorAttestationResponse;
+import com.yubico.webauthn.data.AuthenticatorTransport;
+import com.yubico.webauthn.data.ByteArray;
+import com.yubico.webauthn.data.ClientAssertionExtensionOutputs;
+import com.yubico.webauthn.data.ClientRegistrationExtensionOutputs;
+import com.yubico.webauthn.data.PublicKeyCredential;
+import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions;
+import com.yubico.webauthn.data.UserIdentity;
+import com.yubico.webauthn.exception.AssertionFailedException;
+import com.yubico.webauthn.exception.RegistrationFailedException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.io.IOException;
+import java.time.LocalDateTime;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+@Service
+public class PasskeyAuthService {
+
+ private final RelyingParty relyingParty;
+ private final ObjectMapper objectMapper;
+ private final WebAuthnProperties webAuthnProperties;
+ private final WebAuthnChallengeRepository webAuthnChallengeRepository;
+ private final WebAuthnCredentialRepository webAuthnCredentialRepository;
+ private final SecurityUtils securityUtils;
+ private final AuthService authService;
+ private final UserRepository userRepository;
+
+ public PasskeyAuthService(RelyingParty relyingParty,
+ ObjectMapper objectMapper,
+ WebAuthnProperties webAuthnProperties,
+ WebAuthnChallengeRepository webAuthnChallengeRepository,
+ WebAuthnCredentialRepository webAuthnCredentialRepository,
+ SecurityUtils securityUtils,
+ AuthService authService,
+ UserRepository userRepository) {
+ this.relyingParty = relyingParty;
+ this.objectMapper = objectMapper;
+ this.webAuthnProperties = webAuthnProperties;
+ this.webAuthnChallengeRepository = webAuthnChallengeRepository;
+ this.webAuthnCredentialRepository = webAuthnCredentialRepository;
+ this.securityUtils = securityUtils;
+ this.authService = authService;
+ this.userRepository = userRepository;
+ }
+
+ @Transactional
+ public PasskeyOptionsResponse registerOptions() {
+ User user = securityUtils.getCurrentUser();
+ UserIdentity userIdentity = UserIdentity.builder()
+ .name(user.getEmail())
+ .displayName(user.getName())
+ .id(uuidToByteArray(user.getId()))
+ .build();
+
+ PublicKeyCredentialCreationOptions options = relyingParty.startRegistration(
+ StartRegistrationOptions.builder()
+ .user(userIdentity)
+ .timeout(webAuthnProperties.challengeTimeoutSeconds() * 1000L)
+ .build()
+ );
+
+ WebAuthnChallenge challenge = persistChallenge(
+ user,
+ WebAuthnChallengeType.REGISTRATION,
+ toJsonSafely(options),
+ options.getChallenge().getBase64Url()
+ );
+
+ return new PasskeyOptionsResponse(true, challenge.getId(), readJson(toCredentialsCreateJsonSafely(options)));
+ }
+
+ @Transactional
+ public MessageResponse registerVerify(PasskeyVerifyRequest request) {
+ User user = securityUtils.getCurrentUser();
+ WebAuthnChallenge challenge = resolveUsableChallenge(request.challengeId(), WebAuthnChallengeType.REGISTRATION, user);
+ PublicKeyCredentialCreationOptions options = parseRegistrationOptions(challenge.getRequestJson());
+ PublicKeyCredential parsedCredential =
+ parseRegistrationCredential(request.credential());
+
+ try {
+ RegistrationResult result = relyingParty.finishRegistration(
+ FinishRegistrationOptions.builder()
+ .request(options)
+ .response(parsedCredential)
+ .build()
+ );
+
+ String credentialId = result.getKeyId().getId().getBase64Url();
+ if (webAuthnCredentialRepository.findByCredentialId(credentialId).isPresent()) {
+ throw new BadRequestException("Passkey already registered");
+ }
+
+ WebAuthnCredential credential = new WebAuthnCredential();
+ credential.setUser(user);
+ credential.setCredentialId(credentialId);
+ credential.setPublicKeyCose(result.getPublicKeyCose().getBase64Url());
+ credential.setSignCount(result.getSignatureCount());
+ credential.setTransports(parsedCredential.getResponse().getTransports().stream()
+ .map(AuthenticatorTransport::getId)
+ .collect(Collectors.joining(",")));
+ credential.setUserHandle(options.getUser().getId().getBase64Url());
+ webAuthnCredentialRepository.save(credential);
+ consumeChallenge(challenge);
+ return new MessageResponse("Passkey registered successfully");
+ } catch (RegistrationFailedException e) {
+ throw new BadRequestException("Passkey registration verification failed");
+ }
+ }
+
+ @Transactional
+ public PasskeyOptionsResponse loginOptions(PasskeyLoginOptionsRequest request) {
+ User user = userRepository.findByEmail(request.email()).orElse(null);
+ if (user == null || webAuthnCredentialRepository.countByUser(user) == 0) {
+ return new PasskeyOptionsResponse(false, null, null);
+ }
+
+ AssertionRequest assertionRequest = relyingParty.startAssertion(
+ StartAssertionOptions.builder()
+ .username(user.getEmail())
+ .timeout(webAuthnProperties.challengeTimeoutSeconds() * 1000L)
+ .build()
+ );
+
+ WebAuthnChallenge challenge = persistChallenge(
+ user,
+ WebAuthnChallengeType.AUTHENTICATION,
+ toJsonSafely(assertionRequest),
+ assertionRequest.getPublicKeyCredentialRequestOptions().getChallenge().getBase64Url()
+ );
+
+ return new PasskeyOptionsResponse(true, challenge.getId(), readJson(toCredentialsGetJsonSafely(assertionRequest)));
+ }
+
+ @Transactional
+ public AuthResponse loginVerify(PasskeyVerifyRequest request) {
+ WebAuthnChallenge challenge = resolveUsableChallenge(request.challengeId(), WebAuthnChallengeType.AUTHENTICATION, null);
+ AssertionRequest assertionRequest = parseAssertionRequest(challenge.getRequestJson());
+ PublicKeyCredential parsedCredential =
+ parseAssertionCredential(request.credential());
+
+ try {
+ AssertionResult assertionResult = relyingParty.finishAssertion(
+ FinishAssertionOptions.builder()
+ .request(assertionRequest)
+ .response(parsedCredential)
+ .build()
+ );
+
+ if (!assertionResult.isSuccess() || !assertionResult.isSignatureCounterValid()) {
+ throw new UnauthorizedException("Invalid passkey assertion");
+ }
+
+ User user = userRepository.findByEmail(assertionResult.getUsername())
+ .orElseThrow(() -> new UnauthorizedException("User not found"));
+ WebAuthnCredential credential = webAuthnCredentialRepository
+ .findByCredentialId(assertionResult.getCredentialId().getBase64Url())
+ .orElseThrow(() -> new UnauthorizedException("Passkey not registered"));
+ credential.setSignCount(assertionResult.getSignatureCount());
+ webAuthnCredentialRepository.save(credential);
+ consumeChallenge(challenge);
+ return authService.issueAuthTokens(user);
+ } catch (AssertionFailedException e) {
+ throw new UnauthorizedException("Invalid passkey assertion");
+ }
+ }
+
+ @Transactional(readOnly = true)
+ public PasskeyStatusResponse me() {
+ User user = securityUtils.getCurrentUser();
+ return new PasskeyStatusResponse(webAuthnCredentialRepository.countByUser(user) > 0);
+ }
+
+ private WebAuthnChallenge persistChallenge(User user, WebAuthnChallengeType type, String requestJson, String challengeValue) {
+ webAuthnChallengeRepository.deleteAllByExpiresAtBefore(LocalDateTime.now());
+ WebAuthnChallenge challenge = new WebAuthnChallenge();
+ challenge.setUser(user);
+ challenge.setType(type);
+ challenge.setRequestJson(requestJson);
+ challenge.setChallenge(challengeValue);
+ challenge.setUsed(false);
+ challenge.setExpiresAt(LocalDateTime.now().plusSeconds(webAuthnProperties.challengeTimeoutSeconds()));
+ return webAuthnChallengeRepository.save(challenge);
+ }
+
+ private void consumeChallenge(WebAuthnChallenge challenge) {
+ challenge.setUsed(true);
+ webAuthnChallengeRepository.save(challenge);
+ }
+
+ private WebAuthnChallenge resolveUsableChallenge(UUID challengeId, WebAuthnChallengeType type, User expectedUser) {
+ WebAuthnChallenge challenge = webAuthnChallengeRepository.findByIdAndTypeAndUsedFalse(challengeId, type)
+ .orElseThrow(() -> new BadRequestException("Invalid or already used challenge"));
+ if (challenge.getExpiresAt().isBefore(LocalDateTime.now())) {
+ throw new BadRequestException("Challenge has expired");
+ }
+ if (expectedUser != null && (challenge.getUser() == null || !expectedUser.getId().equals(challenge.getUser().getId()))) {
+ throw new UnauthorizedException("Challenge does not belong to authenticated user");
+ }
+ return challenge;
+ }
+
+ private JsonNode readJson(String json) {
+ try {
+ return objectMapper.readTree(json);
+ } catch (JsonProcessingException e) {
+ throw new IllegalStateException("Failed to serialize WebAuthn options", e);
+ }
+ }
+
+ private String toJsonSafely(PublicKeyCredentialCreationOptions options) {
+ try {
+ return options.toJson();
+ } catch (JsonProcessingException e) {
+ throw new IllegalStateException("Failed to serialize WebAuthn registration request", e);
+ }
+ }
+
+ private String toJsonSafely(AssertionRequest assertionRequest) {
+ try {
+ return assertionRequest.toJson();
+ } catch (JsonProcessingException e) {
+ throw new IllegalStateException("Failed to serialize WebAuthn assertion request", e);
+ }
+ }
+
+ private String toCredentialsCreateJsonSafely(PublicKeyCredentialCreationOptions options) {
+ try {
+ return options.toCredentialsCreateJson();
+ } catch (JsonProcessingException e) {
+ throw new IllegalStateException("Failed to serialize WebAuthn registration options", e);
+ }
+ }
+
+ private String toCredentialsGetJsonSafely(AssertionRequest assertionRequest) {
+ try {
+ return assertionRequest.toCredentialsGetJson();
+ } catch (JsonProcessingException e) {
+ throw new IllegalStateException("Failed to serialize WebAuthn assertion options", e);
+ }
+ }
+
+ private PublicKeyCredentialCreationOptions parseRegistrationOptions(String json) {
+ try {
+ return PublicKeyCredentialCreationOptions.fromJson(json);
+ } catch (JsonProcessingException e) {
+ throw new BadRequestException("Invalid registration request payload");
+ }
+ }
+
+ private AssertionRequest parseAssertionRequest(String json) {
+ try {
+ return AssertionRequest.fromJson(json);
+ } catch (JsonProcessingException e) {
+ throw new BadRequestException("Invalid authentication request payload");
+ }
+ }
+
+ private PublicKeyCredential parseRegistrationCredential(JsonNode credential) {
+ try {
+ return PublicKeyCredential.parseRegistrationResponseJson(objectMapper.writeValueAsString(credential));
+ } catch (IOException e) {
+ throw new BadRequestException("Invalid registration credential payload");
+ }
+ }
+
+ private PublicKeyCredential parseAssertionCredential(JsonNode credential) {
+ try {
+ return PublicKeyCredential.parseAssertionResponseJson(objectMapper.writeValueAsString(credential));
+ } catch (IOException e) {
+ throw new BadRequestException("Invalid authentication credential payload");
+ }
+ }
+
+ private ByteArray uuidToByteArray(UUID uuid) {
+ byte[] bytes = new byte[16];
+ long mostSignificant = uuid.getMostSignificantBits();
+ long leastSignificant = uuid.getLeastSignificantBits();
+ for (int i = 0; i < 8; i++) {
+ bytes[i] = (byte) (mostSignificant >>> (56 - (i * 8)));
+ bytes[8 + i] = (byte) (leastSignificant >>> (56 - (i * 8)));
+ }
+ return new ByteArray(bytes);
+ }
+}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 9b96c618..701ee28e 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -76,6 +76,14 @@ app:
oauth-complete-url: ${GOOGLE_DRIVE_OAUTH_COMPLETE_URL:}
authorization-uri: ${GOOGLE_DRIVE_AUTHORIZATION_URI:https://accounts.google.com/o/oauth2/v2/auth}
token-uri: ${GOOGLE_DRIVE_TOKEN_URI:https://oauth2.googleapis.com/token}
+ webauthn:
+ rp-id: ${APP_WEBAUTHN_RP_ID:localhost}
+ rp-name: ${APP_WEBAUTHN_RP_NAME:JobApplyTracker}
+ origins:
+ - ${APP_WEBAUTHN_ORIGIN_1:http://localhost:3000}
+ - ${APP_WEBAUTHN_ORIGIN_2:http://localhost:5173}
+ - ${APP_WEBAUTHN_ORIGIN_3:https://jobapply-web.hugojava.dev}
+ challenge-timeout-seconds: ${APP_WEBAUTHN_CHALLENGE_TIMEOUT_SECONDS:300}
management:
server:
diff --git a/src/main/resources/db/migration/V16__add_webauthn_passkeys.sql b/src/main/resources/db/migration/V16__add_webauthn_passkeys.sql
new file mode 100644
index 00000000..f4e831b1
--- /dev/null
+++ b/src/main/resources/db/migration/V16__add_webauthn_passkeys.sql
@@ -0,0 +1,30 @@
+CREATE TABLE IF NOT EXISTS webauthn_credentials (
+ id BINARY(16) NOT NULL PRIMARY KEY,
+ user_id BINARY(16) NOT NULL,
+ credential_id VARCHAR(512) NOT NULL,
+ public_key_cose TEXT NOT NULL,
+ sign_count BIGINT NOT NULL DEFAULT 0,
+ transports VARCHAR(255),
+ user_handle VARCHAR(255) NOT NULL,
+ created_at DATETIME NOT NULL,
+ updated_at DATETIME NOT NULL,
+ CONSTRAINT uk_webauthn_credential_id UNIQUE (credential_id),
+ INDEX idx_webauthn_credentials_user_id (user_id),
+ INDEX idx_webauthn_credentials_credential_id (credential_id),
+ CONSTRAINT fk_webauthn_credentials_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS webauthn_challenges (
+ id BINARY(16) NOT NULL PRIMARY KEY,
+ user_id BINARY(16),
+ type VARCHAR(32) NOT NULL,
+ request_json TEXT NOT NULL,
+ challenge VARCHAR(512) NOT NULL,
+ used BOOLEAN NOT NULL DEFAULT FALSE,
+ expires_at DATETIME NOT NULL,
+ created_at DATETIME NOT NULL,
+ updated_at DATETIME NOT NULL,
+ INDEX idx_webauthn_challenges_user_id (user_id),
+ INDEX idx_webauthn_challenges_expires_at (expires_at),
+ CONSTRAINT fk_webauthn_challenges_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/src/test/java/com/jobtracker/integration/AuthControllerIT.java b/src/test/java/com/jobtracker/integration/AuthControllerIT.java
index c87cb66f..8a66f4b8 100644
--- a/src/test/java/com/jobtracker/integration/AuthControllerIT.java
+++ b/src/test/java/com/jobtracker/integration/AuthControllerIT.java
@@ -14,6 +14,8 @@
import com.jobtracker.repository.UserGamificationRepository;
import com.jobtracker.repository.UserInterviewMetricsRepository;
import com.jobtracker.repository.UserRepository;
+import com.jobtracker.repository.WebAuthnChallengeRepository;
+import com.jobtracker.repository.WebAuthnCredentialRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -46,6 +48,8 @@ class AuthControllerIT extends AbstractIntegrationTest {
@Autowired private UserGamificationRepository userGamificationRepository;
@Autowired private UserAchievementRepository userAchievementRepository;
@Autowired private UserInterviewMetricsRepository userInterviewMetricsRepository;
+ @Autowired private WebAuthnChallengeRepository webAuthnChallengeRepository;
+ @Autowired private WebAuthnCredentialRepository webAuthnCredentialRepository;
@BeforeEach
void cleanDb() {
@@ -56,6 +60,8 @@ void cleanDb() {
applicationRepository.deleteAll();
passwordResetTokenRepository.deleteAll();
refreshTokenRepository.deleteAll();
+ webAuthnChallengeRepository.deleteAll();
+ webAuthnCredentialRepository.deleteAll();
userInterviewMetricsRepository.deleteAll();
userRepository.deleteAll();
}
@@ -374,4 +380,44 @@ void changePassword_shouldReturn400_whenCurrentPasswordIsInvalid() throws Except
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value("Current password is incorrect"));
}
+
+ @Test
+ void passkeyLoginOptions_shouldReturnFallbackWhenUserHasNoPasskeys() throws Exception {
+ RegisterRequest reg = new RegisterRequest("No Passkey User", "no-passkey@example.com", "pass1234", "pass1234");
+ mockMvc.perform(post("/api/v1/auth/register")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(reg)))
+ .andExpect(status().isCreated());
+
+ mockMvc.perform(post("/api/v1/auth/passkey/login/options")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"email\":\"no-passkey@example.com\"}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.passkeyAvailable").value(false))
+ .andExpect(jsonPath("$.challengeId").doesNotExist())
+ .andExpect(jsonPath("$.publicKey").doesNotExist());
+ }
+
+ @Test
+ void passkeyRegisterOptions_shouldReturn403WhenNotAuthenticated() throws Exception {
+ mockMvc.perform(post("/api/v1/auth/passkey/register/options")
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isForbidden());
+ }
+
+ @Test
+ void passkeyMe_shouldReturnHasPasskeysFalseByDefault() throws Exception {
+ RegisterRequest reg = new RegisterRequest("Passkey Status User", "passkey-status@example.com", "pass1234", "pass1234");
+ MvcResult regResult = mockMvc.perform(post("/api/v1/auth/register")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(reg)))
+ .andReturn();
+
+ AuthResponse auth = objectMapper.readValue(regResult.getResponse().getContentAsString(), AuthResponse.class);
+
+ mockMvc.perform(get("/api/v1/auth/passkey/me")
+ .header("Authorization", "Bearer " + auth.accessToken()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.hasPasskeys").value(false));
+ }
}
diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml
index 2bc77955..3be6959b 100644
--- a/src/test/resources/application-test.yml
+++ b/src/test/resources/application-test.yml
@@ -36,6 +36,13 @@ app:
client-secret: test-google-client-secret
redirect-uri: http://localhost:8080/api/v1/google-drive/oauth/callback
oauth-complete-url: http://localhost:5173/settings/google-drive/callback
+ webauthn:
+ rp-id: localhost
+ rp-name: JobApplyTracker Test
+ origins:
+ - http://localhost:3000
+ - http://localhost:5173
+ challenge-timeout-seconds: 300
cors:
allowed-origins: http://localhost:3000