Browse Source

Add Support JdbcUserCredentialRepository

Closes gh-16224
Max Batischev 8 tháng trước cách đây
mục cha
commit
7b07ef5ff3

+ 40 - 0
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");
+	}
+
+}

+ 305 - 0
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.
+ *
+ * <b>NOTE:</b> 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<CredentialRecord> credentialRecordRowMapper = new CredentialRecordRowMapper();
+
+	private Function<CredentialRecord, List<SqlParameterValue>> 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<SqlParameterValue> 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<CredentialRecord> result = this.jdbcOperations.query(FIND_CREDENTIAL_RECORD_BY_ID_SQL,
+				this.credentialRecordRowMapper, credentialId.toBase64UrlString());
+		return !result.isEmpty() ? result.get(0) : null;
+	}
+
+	@Override
+	public List<CredentialRecord> 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<CredentialRecord, List<SqlParameterValue>> {
+
+		@Override
+		public List<SqlParameterValue> apply(CredentialRecord record) {
+			List<SqlParameterValue> parameters = new ArrayList<>();
+
+			List<String> 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<CredentialRecord> {
+
+		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<AuthenticatorTransport> 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();
+		}
+
+	}
+
+}

+ 2 - 1
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

+ 18 - 0
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)
+);

+ 59 - 0
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<String> getClientRecordsSqlFiles() {
+		return Stream.of("org/springframework/security/user-credentials-schema.sql");
+	}
+
+}

+ 21 - 0
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 final class TestCredentialRecord {
 			.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() {
 	}
 

+ 180 - 0
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<CredentialRecord> credentialRecords = this.jdbcUserCredentialRepository
+			.findByUserId(userCredential.getUserEntityUserId());
+
+		assertThat(credentialRecords).isNotNull();
+		assertThat(credentialRecords.size()).isEqualTo(1);
+	}
+
+	@Test
+	void findCredentialRecordByUserIdWhenRecordDoesNotExistThenReturnsEmpty() {
+		CredentialRecord userCredential = TestCredentialRecord.fullUserCredential().build();
+
+		List<CredentialRecord> 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();
+	}
+
+}