diff --git a/web/src/main/java/org/springframework/security/web/aot/hint/UserCredentialRuntimeHints.java b/web/src/main/java/org/springframework/security/web/aot/hint/UserCredentialRuntimeHints.java
new file mode 100644
index 00000000000..c3b4c95a148
--- /dev/null
+++ b/web/src/main/java/org/springframework/security/web/aot/hint/UserCredentialRuntimeHints.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2002-2024 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
+ *
+ * https://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 org.springframework.security.web.aot.hint;
+
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.RuntimeHintsRegistrar;
+import org.springframework.jdbc.core.JdbcOperations;
+import org.springframework.security.web.webauthn.api.CredentialRecord;
+import org.springframework.security.web.webauthn.management.UserCredentialRepository;
+
+/**
+ *
+ * A JDBC implementation of an {@link UserCredentialRepository} that uses a
+ * {@link JdbcOperations} for {@link CredentialRecord} persistence.
+ *
+ * @author Max Batischev
+ * @since 6.5
+ */
+class UserCredentialRuntimeHints implements RuntimeHintsRegistrar {
+
+ @Override
+ public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
+ hints.resources().registerPattern("org/springframework/security/user-credentials-schema.sql");
+ }
+
+}
diff --git a/web/src/main/java/org/springframework/security/web/webauthn/management/JdbcUserCredentialRepository.java b/web/src/main/java/org/springframework/security/web/webauthn/management/JdbcUserCredentialRepository.java
new file mode 100644
index 00000000000..aa012d6964b
--- /dev/null
+++ b/web/src/main/java/org/springframework/security/web/webauthn/management/JdbcUserCredentialRepository.java
@@ -0,0 +1,305 @@
+/*
+ * Copyright 2002-2024 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
+ *
+ * https://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 org.springframework.security.web.webauthn.management;
+
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Timestamp;
+import java.sql.Types;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.function.Function;
+
+import org.springframework.jdbc.core.ArgumentPreparedStatementSetter;
+import org.springframework.jdbc.core.JdbcOperations;
+import org.springframework.jdbc.core.PreparedStatementSetter;
+import org.springframework.jdbc.core.RowMapper;
+import org.springframework.jdbc.core.SqlParameterValue;
+import org.springframework.jdbc.support.lob.DefaultLobHandler;
+import org.springframework.jdbc.support.lob.LobCreator;
+import org.springframework.jdbc.support.lob.LobHandler;
+import org.springframework.security.web.webauthn.api.AuthenticatorTransport;
+import org.springframework.security.web.webauthn.api.Bytes;
+import org.springframework.security.web.webauthn.api.CredentialRecord;
+import org.springframework.security.web.webauthn.api.ImmutableCredentialRecord;
+import org.springframework.security.web.webauthn.api.ImmutablePublicKeyCose;
+import org.springframework.security.web.webauthn.api.PublicKeyCredentialType;
+import org.springframework.util.Assert;
+import org.springframework.util.CollectionUtils;
+
+/**
+ * A JDBC implementation of an {@link UserCredentialRepository} that uses a
+ * {@link JdbcOperations} for {@link CredentialRecord} persistence.
+ *
+ * NOTE: This {@code UserCredentialRepository} depends on the table definition
+ * described in "classpath:org/springframework/security/user-credentials-schema.sql" and
+ * therefore MUST be defined in the database schema.
+ *
+ * @author Max Batischev
+ * @since 6.5
+ * @see UserCredentialRepository
+ * @see CredentialRecord
+ * @see JdbcOperations
+ * @see RowMapper
+ */
+public final class JdbcUserCredentialRepository implements UserCredentialRepository {
+
+ private RowMapper credentialRecordRowMapper = new CredentialRecordRowMapper();
+
+ private Function> credentialRecordParametersMapper = new CredentialRecordParametersMapper();
+
+ private LobHandler lobHandler = new DefaultLobHandler();
+
+ private final JdbcOperations jdbcOperations;
+
+ private static final String TABLE_NAME = "user_credentials";
+
+ // @formatter:off
+ private static final String COLUMN_NAMES = "credential_id, "
+ + "user_entity_user_id, "
+ + "public_key, "
+ + "signature_count, "
+ + "uv_initialized, "
+ + "backup_eligible, "
+ + "authenticator_transports, "
+ + "public_key_credential_type, "
+ + "backup_state, "
+ + "attestation_object, "
+ + "attestation_client_data_json, "
+ + "created, "
+ + "last_used, "
+ + "label ";
+ // @formatter:on
+
+ // @formatter:off
+ private static final String SAVE_CREDENTIAL_RECORD_SQL = "INSERT INTO " + TABLE_NAME
+ + " (" + COLUMN_NAMES + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
+ // @formatter:on
+
+ private static final String ID_FILTER = "credential_id = ? ";
+
+ private static final String USER_ID_FILTER = "user_entity_user_id = ? ";
+
+ // @formatter:off
+ private static final String FIND_CREDENTIAL_RECORD_BY_ID_SQL = "SELECT " + COLUMN_NAMES
+ + " FROM " + TABLE_NAME
+ + " WHERE " + ID_FILTER;
+ // @formatter:on
+
+ // @formatter:off
+ private static final String FIND_CREDENTIAL_RECORD_BY_USER_ID_SQL = "SELECT " + COLUMN_NAMES
+ + " FROM " + TABLE_NAME
+ + " WHERE " + USER_ID_FILTER;
+ // @formatter:on
+
+ private static final String DELETE_CREDENTIAL_RECORD_SQL = "DELETE FROM " + TABLE_NAME + " WHERE " + ID_FILTER;
+
+ /**
+ * Constructs a {@code JdbcUserCredentialRepository} using the provided parameters.
+ * @param jdbcOperations the JDBC operations
+ */
+ public JdbcUserCredentialRepository(JdbcOperations jdbcOperations) {
+ Assert.notNull(jdbcOperations, "jdbcOperations cannot be null");
+ this.jdbcOperations = jdbcOperations;
+ }
+
+ @Override
+ public void delete(Bytes credentialId) {
+ Assert.notNull(credentialId, "credentialId cannot be null");
+ SqlParameterValue[] parameters = new SqlParameterValue[] {
+ new SqlParameterValue(Types.VARCHAR, credentialId.toBase64UrlString()), };
+ PreparedStatementSetter pss = new ArgumentPreparedStatementSetter(parameters);
+ this.jdbcOperations.update(DELETE_CREDENTIAL_RECORD_SQL, pss);
+ }
+
+ @Override
+ public void save(CredentialRecord record) {
+ Assert.notNull(record, "record cannot be null");
+ List parameters = this.credentialRecordParametersMapper.apply(record);
+ try (LobCreator lobCreator = this.lobHandler.getLobCreator()) {
+ PreparedStatementSetter pss = new LobCreatorArgumentPreparedStatementSetter(lobCreator,
+ parameters.toArray());
+ this.jdbcOperations.update(SAVE_CREDENTIAL_RECORD_SQL, pss);
+ }
+ }
+
+ @Override
+ public CredentialRecord findByCredentialId(Bytes credentialId) {
+ Assert.notNull(credentialId, "credentialId cannot be null");
+ List result = this.jdbcOperations.query(FIND_CREDENTIAL_RECORD_BY_ID_SQL,
+ this.credentialRecordRowMapper, credentialId.toBase64UrlString());
+ return !result.isEmpty() ? result.get(0) : null;
+ }
+
+ @Override
+ public List findByUserId(Bytes userId) {
+ Assert.notNull(userId, "userId cannot be null");
+ return this.jdbcOperations.query(FIND_CREDENTIAL_RECORD_BY_USER_ID_SQL, this.credentialRecordRowMapper,
+ userId.toBase64UrlString());
+ }
+
+ /**
+ * Sets a {@link LobHandler} for large binary fields and large text field parameters.
+ * @param lobHandler the lob handler
+ */
+ public void setLobHandler(LobHandler lobHandler) {
+ Assert.notNull(lobHandler, "lobHandler cannot be null");
+ this.lobHandler = lobHandler;
+ }
+
+ private static class CredentialRecordParametersMapper
+ implements Function> {
+
+ @Override
+ public List apply(CredentialRecord record) {
+ List parameters = new ArrayList<>();
+
+ List transports = new ArrayList<>();
+ if (!CollectionUtils.isEmpty(record.getTransports())) {
+ for (AuthenticatorTransport transport : record.getTransports()) {
+ transports.add(transport.getValue());
+ }
+ }
+
+ parameters.add(new SqlParameterValue(Types.VARCHAR, record.getCredentialId().toBase64UrlString()));
+ parameters.add(new SqlParameterValue(Types.VARCHAR, record.getUserEntityUserId().toBase64UrlString()));
+ parameters.add(new SqlParameterValue(Types.BLOB, record.getPublicKey().getBytes()));
+ parameters.add(new SqlParameterValue(Types.BIGINT, record.getSignatureCount()));
+ parameters.add(new SqlParameterValue(Types.BOOLEAN, record.isUvInitialized()));
+ parameters.add(new SqlParameterValue(Types.BOOLEAN, record.isBackupEligible()));
+ parameters.add(new SqlParameterValue(Types.VARCHAR,
+ (!CollectionUtils.isEmpty(record.getTransports())) ? String.join(",", transports) : ""));
+ parameters.add(new SqlParameterValue(Types.VARCHAR,
+ (record.getCredentialType() != null) ? record.getCredentialType().getValue() : null));
+ parameters.add(new SqlParameterValue(Types.BOOLEAN, record.isBackupState()));
+ parameters.add(new SqlParameterValue(Types.BLOB,
+ (record.getAttestationObject() != null) ? record.getAttestationObject().getBytes() : null));
+ parameters.add(new SqlParameterValue(Types.BLOB, (record.getAttestationClientDataJSON() != null)
+ ? record.getAttestationClientDataJSON().getBytes() : null));
+ parameters.add(new SqlParameterValue(Types.TIMESTAMP, fromInstant(record.getCreated())));
+ parameters.add(new SqlParameterValue(Types.TIMESTAMP, fromInstant(record.getLastUsed())));
+ parameters.add(new SqlParameterValue(Types.VARCHAR, record.getLabel()));
+
+ return parameters;
+ }
+
+ private Timestamp fromInstant(Instant instant) {
+ if (instant == null) {
+ return null;
+ }
+ return Timestamp.from(instant);
+ }
+
+ }
+
+ private static final class LobCreatorArgumentPreparedStatementSetter extends ArgumentPreparedStatementSetter {
+
+ private final LobCreator lobCreator;
+
+ private LobCreatorArgumentPreparedStatementSetter(LobCreator lobCreator, Object[] args) {
+ super(args);
+ this.lobCreator = lobCreator;
+ }
+
+ @Override
+ protected void doSetValue(PreparedStatement ps, int parameterPosition, Object argValue) throws SQLException {
+ if (argValue instanceof SqlParameterValue paramValue) {
+ if (paramValue.getSqlType() == Types.BLOB) {
+ if (paramValue.getValue() != null) {
+ Assert.isInstanceOf(byte[].class, paramValue.getValue(),
+ "Value of blob parameter must be byte[]");
+ }
+ byte[] valueBytes = (byte[]) paramValue.getValue();
+ this.lobCreator.setBlobAsBytes(ps, parameterPosition, valueBytes);
+ return;
+ }
+ }
+ super.doSetValue(ps, parameterPosition, argValue);
+ }
+
+ }
+
+ private static class CredentialRecordRowMapper implements RowMapper {
+
+ private LobHandler lobHandler = new DefaultLobHandler();
+
+ @Override
+ public CredentialRecord mapRow(ResultSet rs, int rowNum) throws SQLException {
+ Bytes credentialId = Bytes.fromBase64(new String(rs.getString("credential_id").getBytes()));
+ Bytes userEntityUserId = Bytes.fromBase64(new String(rs.getString("user_entity_user_id").getBytes()));
+ ImmutablePublicKeyCose publicKey = new ImmutablePublicKeyCose(
+ this.lobHandler.getBlobAsBytes(rs, "public_key"));
+ long signatureCount = rs.getLong("signature_count");
+ boolean uvInitialized = rs.getBoolean("uv_initialized");
+ boolean backupEligible = rs.getBoolean("backup_eligible");
+ PublicKeyCredentialType credentialType = PublicKeyCredentialType
+ .valueOf(rs.getString("public_key_credential_type"));
+ boolean backupState = rs.getBoolean("backup_state");
+
+ Bytes attestationObject = null;
+ byte[] rawAttestationObject = this.lobHandler.getBlobAsBytes(rs, "attestation_object");
+ if (rawAttestationObject != null) {
+ attestationObject = new Bytes(rawAttestationObject);
+ }
+
+ Bytes attestationClientDataJson = null;
+ byte[] rawAttestationClientDataJson = this.lobHandler.getBlobAsBytes(rs, "attestation_client_data_json");
+ if (rawAttestationClientDataJson != null) {
+ attestationClientDataJson = new Bytes(rawAttestationClientDataJson);
+ }
+
+ Instant created = fromTimestamp(rs.getTimestamp("created"));
+ Instant lastUsed = fromTimestamp(rs.getTimestamp("last_used"));
+ String label = rs.getString("label");
+ String[] transports = rs.getString("authenticator_transports").split(",");
+
+ Set authenticatorTransports = new HashSet<>();
+ for (String transport : transports) {
+ authenticatorTransports.add(AuthenticatorTransport.valueOf(transport));
+ }
+ return ImmutableCredentialRecord.builder()
+ .credentialId(credentialId)
+ .userEntityUserId(userEntityUserId)
+ .publicKey(publicKey)
+ .signatureCount(signatureCount)
+ .uvInitialized(uvInitialized)
+ .backupEligible(backupEligible)
+ .credentialType(credentialType)
+ .backupState(backupState)
+ .attestationObject(attestationObject)
+ .attestationClientDataJSON(attestationClientDataJson)
+ .created(created)
+ .label(label)
+ .lastUsed(lastUsed)
+ .transports(authenticatorTransports)
+ .build();
+ }
+
+ private Instant fromTimestamp(Timestamp timestamp) {
+ if (timestamp == null) {
+ return null;
+ }
+ return timestamp.toInstant();
+ }
+
+ }
+
+}
diff --git a/web/src/main/resources/META-INF/spring/aot.factories b/web/src/main/resources/META-INF/spring/aot.factories
index dcc4be6a067..4c3991233fc 100644
--- a/web/src/main/resources/META-INF/spring/aot.factories
+++ b/web/src/main/resources/META-INF/spring/aot.factories
@@ -1,2 +1,3 @@
org.springframework.aot.hint.RuntimeHintsRegistrar=\
-org.springframework.security.web.aot.hint.WebMvcSecurityRuntimeHints
+org.springframework.security.web.aot.hint.WebMvcSecurityRuntimeHints,\
+org.springframework.security.web.aot.hint.UserCredentialRuntimeHints
diff --git a/web/src/main/resources/org/springframework/security/user-credentials-schema.sql b/web/src/main/resources/org/springframework/security/user-credentials-schema.sql
new file mode 100644
index 00000000000..1be48f2fb1e
--- /dev/null
+++ b/web/src/main/resources/org/springframework/security/user-credentials-schema.sql
@@ -0,0 +1,18 @@
+create table user_credentials
+(
+ credential_id varchar(1000) not null,
+ user_entity_user_id varchar(1000) not null,
+ public_key blob not null,
+ signature_count bigint,
+ uv_initialized boolean,
+ backup_eligible boolean not null,
+ authenticator_transports varchar(1000),
+ public_key_credential_type varchar(100),
+ backup_state boolean not null,
+ attestation_object blob,
+ attestation_client_data_json blob,
+ created timestamp,
+ last_used timestamp,
+ label varchar(1000) not null,
+ primary key (credential_id)
+);
diff --git a/web/src/test/java/org/springframework/security/web/aot/hint/UserCredentialRuntimeHintsTests.java b/web/src/test/java/org/springframework/security/web/aot/hint/UserCredentialRuntimeHintsTests.java
new file mode 100644
index 00000000000..33799cc6f92
--- /dev/null
+++ b/web/src/test/java/org/springframework/security/web/aot/hint/UserCredentialRuntimeHintsTests.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2002-2024 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
+ *
+ * https://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 org.springframework.security.web.aot.hint;
+
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.RuntimeHintsRegistrar;
+import org.springframework.aot.hint.predicate.RuntimeHintsPredicates;
+import org.springframework.core.io.support.SpringFactoriesLoader;
+import org.springframework.util.ClassUtils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link UserCredentialRuntimeHints}
+ *
+ * @author Max Batischev
+ */
+public class UserCredentialRuntimeHintsTests {
+
+ private final RuntimeHints hints = new RuntimeHints();
+
+ @BeforeEach
+ void setup() {
+ SpringFactoriesLoader.forResourceLocation("META-INF/spring/aot.factories")
+ .load(RuntimeHintsRegistrar.class)
+ .forEach((registrar) -> registrar.registerHints(this.hints, ClassUtils.getDefaultClassLoader()));
+ }
+
+ @ParameterizedTest
+ @MethodSource("getClientRecordsSqlFiles")
+ void credentialRecordsSqlFilesHasHints(String schemaFile) {
+ assertThat(RuntimeHintsPredicates.resource().forResource(schemaFile)).accepts(this.hints);
+ }
+
+ private static Stream getClientRecordsSqlFiles() {
+ return Stream.of("org/springframework/security/user-credentials-schema.sql");
+ }
+
+}
diff --git a/web/src/test/java/org/springframework/security/web/webauthn/api/TestCredentialRecord.java b/web/src/test/java/org/springframework/security/web/webauthn/api/TestCredentialRecord.java
index 917125ae670..1ed190c03d5 100644
--- a/web/src/test/java/org/springframework/security/web/webauthn/api/TestCredentialRecord.java
+++ b/web/src/test/java/org/springframework/security/web/webauthn/api/TestCredentialRecord.java
@@ -16,6 +16,9 @@
package org.springframework.security.web.webauthn.api;
+import java.time.Instant;
+import java.util.Set;
+
public final class TestCredentialRecord {
public static ImmutableCredentialRecord.ImmutableCredentialRecordBuilder userCredential() {
@@ -29,6 +32,24 @@ public static ImmutableCredentialRecord.ImmutableCredentialRecordBuilder userCre
.backupState(true);
}
+ public static ImmutableCredentialRecord.ImmutableCredentialRecordBuilder fullUserCredential() {
+ return ImmutableCredentialRecord.builder()
+ .label("label")
+ .credentialId(Bytes.fromBase64("NauGCN7bZ5jEBwThcde51g"))
+ .userEntityUserId(Bytes.fromBase64("vKBFhsWT3gQnn-gHdT4VXIvjDkVXVYg5w8CLGHPunMM"))
+ .publicKey(ImmutablePublicKeyCose.fromBase64(
+ "pQECAyYgASFYIC7DAiV_trHFPjieOxXbec7q2taBcgLnIi19zrUwVhCdIlggvN6riHORK_velHcTLFK_uJhyKK0oBkJqzNqR2E-2xf8="))
+ .backupEligible(true)
+ .created(Instant.now())
+ .transports(Set.of(AuthenticatorTransport.BLE, AuthenticatorTransport.HYBRID))
+ .signatureCount(100)
+ .uvInitialized(false)
+ .credentialType(PublicKeyCredentialType.PUBLIC_KEY)
+ .attestationObject(new Bytes("test".getBytes()))
+ .attestationClientDataJSON(new Bytes(("test").getBytes()))
+ .backupState(true);
+ }
+
private TestCredentialRecord() {
}
diff --git a/web/src/test/java/org/springframework/security/web/webauthn/management/JdbcUserCredentialRepositoryTests.java b/web/src/test/java/org/springframework/security/web/webauthn/management/JdbcUserCredentialRepositoryTests.java
new file mode 100644
index 00000000000..4829b537f0a
--- /dev/null
+++ b/web/src/test/java/org/springframework/security/web/webauthn/management/JdbcUserCredentialRepositoryTests.java
@@ -0,0 +1,180 @@
+/*
+ * Copyright 2002-2024 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
+ *
+ * https://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 org.springframework.security.web.webauthn.management;
+
+import java.util.List;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.jdbc.core.JdbcOperations;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;
+import org.springframework.security.web.webauthn.api.AuthenticatorTransport;
+import org.springframework.security.web.webauthn.api.CredentialRecord;
+import org.springframework.security.web.webauthn.api.PublicKeyCredentialType;
+import org.springframework.security.web.webauthn.api.TestCredentialRecord;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link JdbcUserCredentialRepository}
+ *
+ * @author Max Batischev
+ */
+public class JdbcUserCredentialRepositoryTests {
+
+ private EmbeddedDatabase db;
+
+ private JdbcUserCredentialRepository jdbcUserCredentialRepository;
+
+ private static final String USER_CREDENTIALS_SQL_RESOURCE = "org/springframework/security/user-credentials-schema.sql";
+
+ @BeforeEach
+ void setUp() {
+ this.db = createDb();
+ JdbcOperations jdbcOperations = new JdbcTemplate(this.db);
+ this.jdbcUserCredentialRepository = new JdbcUserCredentialRepository(jdbcOperations);
+ }
+
+ @AfterEach
+ void tearDown() {
+ this.db.shutdown();
+ }
+
+ private static EmbeddedDatabase createDb() {
+ // @formatter:off
+ return new EmbeddedDatabaseBuilder()
+ .generateUniqueName(true)
+ .setType(EmbeddedDatabaseType.HSQL)
+ .setScriptEncoding("UTF-8")
+ .addScript(USER_CREDENTIALS_SQL_RESOURCE)
+ .build();
+ // @formatter:on
+ }
+
+ @Test
+ void constructorWhenJdbcOperationsIsNullThenThrowIllegalArgumentException() {
+ // @formatter:off
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> new JdbcUserCredentialRepository(null))
+ .withMessage("jdbcOperations cannot be null");
+ // @formatter:on
+ }
+
+ @Test
+ void saveWhenCredentialRecordIsNullThenThrowIllegalArgumentException() {
+ // @formatter:off
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> this.jdbcUserCredentialRepository.save(null))
+ .withMessage("record cannot be null");
+ // @formatter:on
+ }
+
+ @Test
+ void findByCredentialIdWheCredentialIdIsNullThenThrowIllegalArgumentException() {
+ // @formatter:off
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> this.jdbcUserCredentialRepository.findByCredentialId(null))
+ .withMessage("credentialId cannot be null");
+ // @formatter:on
+ }
+
+ @Test
+ void findByCredentialIdWheUserIdIsNullThenThrowIllegalArgumentException() {
+ // @formatter:off
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> this.jdbcUserCredentialRepository.findByUserId(null))
+ .withMessage("userId cannot be null");
+ // @formatter:on
+ }
+
+ @Test
+ void saveCredentialRecordWhenSaveThenReturnsSaved() {
+ CredentialRecord userCredential = TestCredentialRecord.fullUserCredential().build();
+ this.jdbcUserCredentialRepository.save(userCredential);
+
+ CredentialRecord savedUserCredential = this.jdbcUserCredentialRepository
+ .findByCredentialId(userCredential.getCredentialId());
+
+ assertThat(savedUserCredential).isNotNull();
+ assertThat(savedUserCredential.getCredentialId()).isEqualTo(userCredential.getCredentialId());
+ assertThat(savedUserCredential.getUserEntityUserId()).isEqualTo(userCredential.getUserEntityUserId());
+ assertThat(savedUserCredential.getLabel()).isEqualTo(userCredential.getLabel());
+ assertThat(savedUserCredential.getPublicKey().getBytes()).isEqualTo(userCredential.getPublicKey().getBytes());
+ assertThat(savedUserCredential.isBackupEligible()).isEqualTo(userCredential.isBackupEligible());
+ assertThat(savedUserCredential.isBackupState()).isEqualTo(userCredential.isBackupState());
+ assertThat(savedUserCredential.getCreated()).isNotNull();
+ assertThat(savedUserCredential.getLastUsed()).isNotNull();
+ assertThat(savedUserCredential.isUvInitialized()).isFalse();
+ assertThat(savedUserCredential.getSignatureCount()).isEqualTo(100);
+ assertThat(savedUserCredential.getCredentialType()).isEqualTo(PublicKeyCredentialType.PUBLIC_KEY);
+ assertThat(savedUserCredential.getTransports().contains(AuthenticatorTransport.HYBRID)).isTrue();
+ assertThat(savedUserCredential.getTransports().contains(AuthenticatorTransport.BLE)).isTrue();
+ assertThat(new String(savedUserCredential.getAttestationObject().getBytes())).isEqualTo("test");
+ assertThat(new String(savedUserCredential.getAttestationClientDataJSON().getBytes())).isEqualTo("test");
+ }
+
+ @Test
+ void findCredentialRecordByUserIdWhenRecordExistsThenReturnsSaved() {
+ CredentialRecord userCredential = TestCredentialRecord.fullUserCredential().build();
+ this.jdbcUserCredentialRepository.save(userCredential);
+
+ List credentialRecords = this.jdbcUserCredentialRepository
+ .findByUserId(userCredential.getUserEntityUserId());
+
+ assertThat(credentialRecords).isNotNull();
+ assertThat(credentialRecords.size()).isEqualTo(1);
+ }
+
+ @Test
+ void findCredentialRecordByUserIdWhenRecordDoesNotExistThenReturnsEmpty() {
+ CredentialRecord userCredential = TestCredentialRecord.fullUserCredential().build();
+
+ List credentialRecords = this.jdbcUserCredentialRepository
+ .findByUserId(userCredential.getUserEntityUserId());
+
+ assertThat(credentialRecords.size()).isEqualTo(0);
+ }
+
+ @Test
+ void findCredentialRecordByCredentialIdWhenRecordDoesNotExistThenReturnsNull() {
+ CredentialRecord userCredential = TestCredentialRecord.fullUserCredential().build();
+
+ CredentialRecord credentialRecord = this.jdbcUserCredentialRepository
+ .findByCredentialId(userCredential.getCredentialId());
+
+ assertThat(credentialRecord).isNull();
+ }
+
+ @Test
+ void deleteCredentialRecordWhenRecordExistThenSuccess() {
+ CredentialRecord userCredential = TestCredentialRecord.fullUserCredential().build();
+ this.jdbcUserCredentialRepository.save(userCredential);
+
+ this.jdbcUserCredentialRepository.delete(userCredential.getCredentialId());
+
+ CredentialRecord credentialRecord = this.jdbcUserCredentialRepository
+ .findByCredentialId(userCredential.getCredentialId());
+ assertThat(credentialRecord).isNull();
+ }
+
+}