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(); + } + +}