Browse Source

JDBC implementation of RegisteredClientRepository

Closes gh-265
Rafal Lewczuk 4 years ago
parent
commit
769cf8fac7

+ 335 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/client/JdbcRegisteredClientRepository.java

@@ -0,0 +1,335 @@
+/*
+ * Copyright 2020-2021 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.oauth2.server.authorization.client;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.jdbc.core.*;
+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.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.server.authorization.config.ClientSettings;
+import org.springframework.security.oauth2.server.authorization.config.TokenSettings;
+import org.springframework.util.Assert;
+
+import java.nio.charset.StandardCharsets;
+import java.sql.*;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.*;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/**
+ * JDBC-backed registered client repository
+ *
+ * @author Rafal Lewczuk
+ * @since 0.1.2
+ */
+public class JdbcRegisteredClientRepository implements RegisteredClientRepository {
+
+	private static final Map<String, AuthorizationGrantType> AUTHORIZATION_GRANT_TYPE_MAP;
+	private static final Map<String, ClientAuthenticationMethod> CLIENT_AUTHENTICATION_METHOD_MAP;
+
+	private static final String COLUMN_NAMES = "id, "
+			+ "client_id, "
+			+ "client_id_issued_at, "
+			+ "client_secret, "
+			+ "client_secret_expires_at, "
+			+ "client_name, "
+			+ "client_authentication_methods, "
+			+ "authorization_grant_types, "
+			+ "redirect_uris, "
+			+ "scopes, "
+			+ "client_settings,"
+			+ "token_settings";
+
+	private static final String TABLE_NAME = "oauth2_registered_client";
+
+	private static final String LOAD_REGISTERED_CLIENT_SQL = "SELECT " + COLUMN_NAMES + " FROM " + TABLE_NAME + " WHERE ";
+
+	private static final String INSERT_REGISTERED_CLIENT_SQL = "INSERT INTO " + TABLE_NAME
+			+ "(" + COLUMN_NAMES + ") values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
+
+	private RowMapper<RegisteredClient> registeredClientRowMapper;
+
+	private Function<RegisteredClient, List<SqlParameterValue>> registeredClientParametersMapper;
+
+	private final JdbcOperations jdbcOperations;
+
+	private final LobHandler lobHandler = new DefaultLobHandler();
+
+	private final ObjectMapper objectMapper;
+
+	public JdbcRegisteredClientRepository(JdbcOperations jdbcOperations, ObjectMapper objectMapper) {
+		Assert.notNull(jdbcOperations, "jdbcOperations cannot be null");
+		Assert.notNull(objectMapper, "objectMapper cannot be null");
+		this.jdbcOperations = jdbcOperations;
+		this.objectMapper = objectMapper;
+		this.registeredClientRowMapper = new DefaultRegisteredClientRowMapper();
+		this.registeredClientParametersMapper = new DefaultRegisteredClientParametersMapper();
+	}
+
+	/**
+	 * Allows changing of {@link RegisteredClient} row mapper implementation
+	 *
+	 * @param registeredClientRowMapper mapper implementation
+	 */
+	public void setRegisteredClientRowMapper(RowMapper<RegisteredClient> registeredClientRowMapper) {
+		Assert.notNull(registeredClientRowMapper, "registeredClientRowMapper cannot be null");
+		this.registeredClientRowMapper = registeredClientRowMapper;
+	}
+
+	/**
+	 * Allows changing of SQL parameter mapper for {@link RegisteredClient}
+	 *
+	 * @param registeredClientParametersMapper mapper implementation
+	 */
+	public void setRegisteredClientParametersMapper(Function<RegisteredClient, List<SqlParameterValue>> registeredClientParametersMapper) {
+		Assert.notNull(registeredClientParametersMapper, "registeredClientParameterMapper cannot be null");
+		this.registeredClientParametersMapper = registeredClientParametersMapper;
+	}
+
+	@Override
+	public void save(RegisteredClient registeredClient) {
+		Assert.notNull(registeredClient, "registeredClient cannot be null");
+		RegisteredClient foundClient = this.findBy("id = ? OR client_id = ? OR client_secret = ?",
+				registeredClient.getId(), registeredClient.getClientId(),
+				registeredClient.getClientSecret().getBytes(StandardCharsets.UTF_8));
+
+		if (null != foundClient) {
+			Assert.isTrue(!foundClient.getId().equals(registeredClient.getId()),
+					"Registered client must be unique. Found duplicate identifier: " + registeredClient.getId());
+			Assert.isTrue(!foundClient.getClientId().equals(registeredClient.getClientId()),
+					"Registered client must be unique. Found duplicate client identifier: " + registeredClient.getClientId());
+			Assert.isTrue(!foundClient.getClientSecret().equals(registeredClient.getClientSecret()),
+					"Registered client must be unique. Found duplicate client secret for identifier: " + registeredClient.getId());
+		}
+
+		List<SqlParameterValue> parameters = this.registeredClientParametersMapper.apply(registeredClient);
+
+		try (LobCreator lobCreator = this.lobHandler.getLobCreator()) {
+			PreparedStatementSetter pss = new LobCreatorArgumentPreparedStatementSetter(lobCreator, parameters.toArray());
+			jdbcOperations.update(INSERT_REGISTERED_CLIENT_SQL, pss);
+		}
+	}
+
+	@Override
+	public RegisteredClient findById(String id) {
+		Assert.hasText(id, "id cannot be empty");
+		return findBy("id = ?", id);
+	}
+
+	@Override
+	public RegisteredClient findByClientId(String clientId) {
+		Assert.hasText(clientId, "clientId cannot be empty");
+		return findBy("client_id = ?", clientId);
+	}
+
+	private RegisteredClient findBy(String condStr, Object...args) {
+		List<RegisteredClient> lst = jdbcOperations.query(
+				LOAD_REGISTERED_CLIENT_SQL + condStr,
+				registeredClientRowMapper, args);
+		return !lst.isEmpty() ? lst.get(0) : null;
+	}
+
+	private class DefaultRegisteredClientRowMapper implements RowMapper<RegisteredClient> {
+
+		private final LobHandler lobHandler = new DefaultLobHandler();
+
+		private Collection<String> parseList(String s) {
+			return s != null ? Arrays.asList(s.split("\\|")) : Collections.emptyList();
+		}
+
+		@Override
+		@SuppressWarnings("unchecked")
+		public RegisteredClient mapRow(ResultSet rs, int rowNum) throws SQLException {
+			Collection<String> scopes = parseList(rs.getString("scopes"));
+			List<AuthorizationGrantType> authGrantTypes = parseList(rs.getString("authorization_grant_types"))
+					.stream().map(AUTHORIZATION_GRANT_TYPE_MAP::get).collect(Collectors.toList());
+			List<ClientAuthenticationMethod> clientAuthMethods = parseList(rs.getString("client_authentication_methods"))
+					.stream().map(CLIENT_AUTHENTICATION_METHOD_MAP::get).collect(Collectors.toList());
+			Collection<String> redirectUris = parseList(rs.getString("redirect_uris"));
+			Timestamp clientIssuedAt = rs.getTimestamp("client_id_issued_at");
+			Timestamp clientSecretExpiresAt = rs.getTimestamp("client_secret_expires_at");
+			byte[] clientSecretBytes = this.lobHandler.getBlobAsBytes(rs, "client_secret");
+			String clientSecret = clientSecretBytes != null ? new String(clientSecretBytes, StandardCharsets.UTF_8) : null;
+			RegisteredClient.Builder builder = RegisteredClient
+					.withId(rs.getString("id"))
+					.clientId(rs.getString("client_id"))
+					.clientIdIssuedAt(clientIssuedAt != null ? clientIssuedAt.toInstant() : null)
+					.clientSecret(clientSecret)
+					.clientSecretExpiresAt(clientSecretExpiresAt != null ? clientSecretExpiresAt.toInstant() : null)
+					.clientName(rs.getString("client_name"))
+					.clientAuthenticationMethods(coll -> coll.addAll(clientAuthMethods))
+					.authorizationGrantTypes(coll -> coll.addAll(authGrantTypes))
+					.redirectUris(coll -> coll.addAll(redirectUris))
+					.scopes(coll -> coll.addAll(scopes));
+
+			RegisteredClient rc = builder.build();
+
+			TokenSettings ts = rc.getTokenSettings();
+			ClientSettings cs = rc.getClientSettings();
+
+			try {
+				String tokenSettingsJson = rs.getString("token_settings");
+				if (tokenSettingsJson != null) {
+
+					Map<String, Object> m = JdbcRegisteredClientRepository.this.objectMapper.readValue(tokenSettingsJson, Map.class);
+
+					Number accessTokenTTL = (Number) m.get("access_token_ttl");
+					if (accessTokenTTL != null) {
+						ts.accessTokenTimeToLive(Duration.ofMillis(accessTokenTTL.longValue()));
+					}
+
+					Number refreshTokenTTL = (Number) m.get("refresh_token_ttl");
+					if (refreshTokenTTL != null) {
+						ts.refreshTokenTimeToLive(Duration.ofMillis(refreshTokenTTL.longValue()));
+					}
+
+					Boolean reuseRefreshTokens = (Boolean) m.get("reuse_refresh_tokens");
+					if (reuseRefreshTokens != null) {
+						ts.reuseRefreshTokens(reuseRefreshTokens);
+					}
+				}
+
+				String clientSettingsJson = rs.getString("client_settings");
+				if (clientSettingsJson != null) {
+
+					Map<String, Object> m = JdbcRegisteredClientRepository.this.objectMapper.readValue(clientSettingsJson, Map.class);
+
+					Boolean requireProofKey = (Boolean) m.get("require_proof_key");
+					if (requireProofKey != null) {
+						cs.requireProofKey(requireProofKey);
+					}
+
+					Boolean requireUserConsent = (Boolean) m.get("require_user_consent");
+					if (requireUserConsent != null) {
+						cs.requireUserConsent(requireUserConsent);
+					}
+				}
+
+
+			} catch (JsonProcessingException e) {
+				throw new IllegalArgumentException(e.getMessage(), e);
+			}
+
+			return rc;
+		}
+	}
+
+	private class DefaultRegisteredClientParametersMapper implements Function<RegisteredClient, List<SqlParameterValue>> {
+		@Override
+		public List<SqlParameterValue> apply(RegisteredClient registeredClient) {
+			try {
+				List<String> clientAuthenticationMethodNames = new ArrayList<>(registeredClient.getClientAuthenticationMethods().size());
+				for (ClientAuthenticationMethod clientAuthenticationMethod : registeredClient.getClientAuthenticationMethods()) {
+					clientAuthenticationMethodNames.add(clientAuthenticationMethod.getValue());
+				}
+
+				List<String> authorizationGrantTypeNames = new ArrayList<>(registeredClient.getAuthorizationGrantTypes().size());
+				for (AuthorizationGrantType authorizationGrantType : registeredClient.getAuthorizationGrantTypes()) {
+					authorizationGrantTypeNames.add(authorizationGrantType.getValue());
+				}
+
+				Instant issuedAt = registeredClient.getClientIdIssuedAt() != null ?
+						registeredClient.getClientIdIssuedAt() : Instant.now();
+
+				Timestamp clientSecretExpiresAt = registeredClient.getClientSecretExpiresAt() != null ?
+						Timestamp.from(registeredClient.getClientSecretExpiresAt()) : null;
+
+				Map<String, Object> clientSettings = new HashMap<>();
+				clientSettings.put("require_proof_key", registeredClient.getClientSettings().requireProofKey());
+				clientSettings.put("require_user_consent", registeredClient.getClientSettings().requireUserConsent());
+				String clientSettingsJson = JdbcRegisteredClientRepository.this.objectMapper.writeValueAsString(clientSettings);
+
+				Map<String, Object> tokenSettings = new HashMap<>();
+				tokenSettings.put("access_token_ttl", registeredClient.getTokenSettings().accessTokenTimeToLive().toMillis());
+				tokenSettings.put("reuse_refresh_tokens", registeredClient.getTokenSettings().reuseRefreshTokens());
+				tokenSettings.put("refresh_token_ttl", registeredClient.getTokenSettings().refreshTokenTimeToLive().toMillis());
+				String tokenSettingsJson = JdbcRegisteredClientRepository.this.objectMapper.writeValueAsString(tokenSettings);
+
+				return Arrays.asList(
+						new SqlParameterValue(Types.VARCHAR, registeredClient.getId()),
+						new SqlParameterValue(Types.VARCHAR, registeredClient.getClientId()),
+						new SqlParameterValue(Types.TIMESTAMP, Timestamp.from(issuedAt)),
+						new SqlParameterValue(Types.BLOB, registeredClient.getClientSecret().getBytes(StandardCharsets.UTF_8)),
+						new SqlParameterValue(Types.TIMESTAMP, clientSecretExpiresAt),
+						new SqlParameterValue(Types.VARCHAR, registeredClient.getClientName()),
+						new SqlParameterValue(Types.VARCHAR, String.join("|", clientAuthenticationMethodNames)),
+						new SqlParameterValue(Types.VARCHAR, String.join("|", authorizationGrantTypeNames)),
+						new SqlParameterValue(Types.VARCHAR, String.join("|", registeredClient.getRedirectUris())),
+						new SqlParameterValue(Types.VARCHAR, String.join("|", registeredClient.getScopes())),
+						new SqlParameterValue(Types.VARCHAR, clientSettingsJson),
+						new SqlParameterValue(Types.VARCHAR, tokenSettingsJson));
+			} catch (JsonProcessingException e) {
+				throw new IllegalArgumentException(e.getMessage(), e);
+			}
+		}
+	}
+
+	private static final class LobCreatorArgumentPreparedStatementSetter extends ArgumentPreparedStatementSetter {
+
+		protected 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) {
+				SqlParameterValue paramValue = (SqlParameterValue) argValue;
+				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);
+		}
+
+	}
+
+	static {
+		Map<String, AuthorizationGrantType> am = new HashMap<>();
+		for (AuthorizationGrantType a : Arrays.asList(
+				AuthorizationGrantType.AUTHORIZATION_CODE,
+				AuthorizationGrantType.REFRESH_TOKEN,
+				AuthorizationGrantType.CLIENT_CREDENTIALS,
+				AuthorizationGrantType.PASSWORD,
+				AuthorizationGrantType.IMPLICIT)) {
+			am.put(a.getValue(), a);
+		}
+		AUTHORIZATION_GRANT_TYPE_MAP = Collections.unmodifiableMap(am);
+
+		Map<String, ClientAuthenticationMethod> cm = new HashMap<>();
+		for (ClientAuthenticationMethod c : Arrays.asList(
+				ClientAuthenticationMethod.NONE,
+				ClientAuthenticationMethod.BASIC,
+				ClientAuthenticationMethod.POST)) {
+			cm.put(c.getValue(), c);
+		}
+		CLIENT_AUTHENTICATION_METHOD_MAP = Collections.unmodifiableMap(cm);
+	}
+}

+ 14 - 0
oauth2-authorization-server/src/main/resources/org/springframework/security/oauth2/server/authorization/client/oauth2_registered_client.sql

@@ -0,0 +1,14 @@
+CREATE TABLE oauth2_registered_client (
+    id varchar(100) NOT NULL,
+    client_id varchar(100) NOT NULL,
+    client_id_issued_at timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
+    client_secret blob NOT NULL,
+    client_secret_expires_at timestamp DEFAULT NULL,
+    client_name varchar(200),
+    client_authentication_methods varchar(1000) NOT NULL,
+    authorization_grant_types varchar(1000) NOT NULL,
+    redirect_uris varchar(1000) NOT NULL,
+    scopes varchar(1000) NOT NULL,
+    client_settings varchar(1000) DEFAULT NULL,
+    token_settings varchar(1000) DEFAULT NULL,
+    PRIMARY KEY (id));

+ 291 - 0
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/client/JdbcRegisteredClientRepositoryTests.java

@@ -0,0 +1,291 @@
+/*
+ * Copyright 2020-2021 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.oauth2.server.authorization.client;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.DriverManagerDataSource;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.util.StreamUtils;
+
+import java.io.InputStream;
+import java.nio.charset.Charset;
+import java.time.Duration;
+import java.time.Instant;
+
+import static org.assertj.core.api.Assertions.*;
+
+/**
+ * JDBC-backed registered client repository tests
+ *
+ * @author Rafal Lewczuk
+ * @since 0.1.2
+ */
+public class JdbcRegisteredClientRepositoryTests {
+
+	private final String SCRIPT = "/org/springframework/security/oauth2/server/authorization/client/oauth2_registered_client.sql";
+
+	private DriverManagerDataSource dataSource;
+
+	private JdbcRegisteredClientRepository clients;
+
+	private RegisteredClient registration;
+
+	private JdbcTemplate jdbc;
+
+	@Before
+	public void setup() throws Exception {
+		this.dataSource = new DriverManagerDataSource();
+		this.dataSource.setDriverClassName("org.hsqldb.jdbcDriver");
+		this.dataSource.setUrl("jdbc:hsqldb:mem:oauthtest");
+		this.dataSource.setUsername("sa");
+		this.dataSource.setPassword("");
+
+		this.jdbc = new JdbcTemplate(this.dataSource);
+
+		// execute scripts
+		try (InputStream is = JdbcRegisteredClientRepositoryTests.class.getResourceAsStream(SCRIPT)) {
+			assertThat(is).isNotNull().describedAs("Cannot open resource file: " + SCRIPT);
+			String ddls = StreamUtils.copyToString(is, Charset.defaultCharset());
+			for (String ddl : ddls.split(";\n")) {
+				if (!ddl.trim().isEmpty()) {
+					this.jdbc.execute(ddl.trim());
+				}
+			}
+		}
+
+		this.clients = new JdbcRegisteredClientRepository(this.jdbc, new ObjectMapper());
+		this.registration = TestRegisteredClients.registeredClient().build();
+
+		this.clients.save(this.registration);
+	}
+
+	@After
+	public void destroyDatabase() {
+		this.jdbc.update("truncate table oauth2_registered_client");
+		new JdbcTemplate(this.dataSource).execute("SHUTDOWN");
+	}
+
+	@Test
+	public void whenJdbcOperationsNullThenThrow() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new JdbcRegisteredClientRepository(null, new ObjectMapper()))
+				.withMessage("jdbcOperations cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void whenObjectMapperNullThenThrow() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new JdbcRegisteredClientRepository(this.jdbc, null))
+				.withMessage("objectMapper cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void whenSetNullRegisteredClientRowMapperThenThrow() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.clients.setRegisteredClientRowMapper(null))
+				.withMessage("registeredClientRowMapper cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void whenSetNullRegisteredClientParameterMapperThenThrow() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.clients.setRegisteredClientParametersMapper(null))
+				.withMessage("registeredClientParameterMapper cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void findByIdWhenFoundThenFound() {
+		String id = this.registration.getId();
+		assertRegisteredClientIsEqualTo(this.clients.findById(id), this.registration);
+	}
+
+	@Test
+	public void findByIdWhenNotFoundThenNull() {
+		RegisteredClient client = this.clients.findById("noooope");
+		assertThat(client).isNull();
+	}
+
+	@Test
+	public void findByIdWhenNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.clients.findById(null))
+				.withMessage("id cannot be empty");
+		// @formatter:on
+	}
+
+	@Test
+	public void findByClientIdWhenFoundThenFound() {
+		String id = this.registration.getClientId();
+		assertRegisteredClientIsEqualTo(this.clients.findByClientId(id), this.registration);
+	}
+
+	@Test
+	public void findByClientIdWhenNotFoundThenNull() {
+		RegisteredClient client = this.clients.findByClientId("noooope");
+		assertThat(client).isNull();
+	}
+
+	@Test
+	public void findByClientIdWhenNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.clients.findByClientId(null))
+				.withMessage("clientId cannot be empty");
+		// @formatter:on
+	}
+
+	@Test
+	public void saveWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.clients.save(null))
+				.withMessageContaining("registeredClient cannot be null");
+	}
+
+	@Test
+	public void saveWhenExistingIdThenThrowIllegalArgumentException() {
+		RegisteredClient registeredClient = createRegisteredClient(
+				this.registration.getId(), "client-id-2", "client-secret-2");
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.clients.save(registeredClient))
+				.withMessage("Registered client must be unique. Found duplicate identifier: " + registeredClient.getId());
+	}
+
+	@Test
+	public void saveWhenExistingClientIdThenThrowIllegalArgumentException() {
+		RegisteredClient registeredClient = createRegisteredClient(
+				"client-2", this.registration.getClientId(), "client-secret-2");
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.clients.save(registeredClient))
+				.withMessage("Registered client must be unique. Found duplicate client identifier: " + registeredClient.getClientId());
+	}
+
+	@Test
+	public void saveWhenExistingClientSecretThenThrowIllegalArgumentException() {
+		RegisteredClient registeredClient = createRegisteredClient(
+				"client-2", "client-id-2", this.registration.getClientSecret());
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.clients.save(registeredClient))
+				.withMessage("Registered client must be unique. Found duplicate client secret for identifier: " + registeredClient.getId());
+	}
+
+	@Test
+	public void saveWhenSavedAndFindByIdThenFound() {
+		RegisteredClient registeredClient = createRegisteredClient();
+		this.clients.save(registeredClient);
+		RegisteredClient savedClient = this.clients.findById(registeredClient.getId());
+		assertRegisteredClientIsEqualTo(savedClient, registeredClient);
+	}
+
+	@Test
+	public void saveWhenSavedAndFindByClientIdThenFound() {
+		RegisteredClient registeredClient = createRegisteredClient();
+		this.clients.save(registeredClient);
+		RegisteredClient savedClient = this.clients.findByClientId(registeredClient.getClientId());
+		assertRegisteredClientIsEqualTo(savedClient, registeredClient);
+	}
+
+	@Test
+	public void whenSaveRegistrationWithAllAttrsThenSaved() {
+		Instant issuedAt = Instant.now(), expiresAt = issuedAt.plus(Duration.ofDays(30));
+		RegisteredClient client = TestRegisteredClients.registeredClient2()
+				.clientIdIssuedAt(issuedAt)
+				.clientSecretExpiresAt(expiresAt)
+				.clientSecret("secret2")
+				.clientName("some_client_name")
+				.redirectUri("https://example2.com")
+				.clientSettings(cs -> {
+					cs.requireProofKey(true);
+					cs.requireUserConsent(true);
+				})
+				.tokenSettings(ts -> {
+					ts.accessTokenTimeToLive(Duration.ofMinutes(3));
+					ts.reuseRefreshTokens(true);
+					ts.refreshTokenTimeToLive(Duration.ofMinutes(300));
+				})
+				.build();
+
+		this.clients.save(client);
+
+		RegisteredClient retrievedClient = this.clients.findById(client.getId());
+
+		assertRegisteredClientIsEqualTo(retrievedClient, client);
+	}
+
+	private void assertRegisteredClientIsEqualTo(RegisteredClient rc, RegisteredClient ref) {
+		assertThat(rc).isNotNull();
+		assertThat(rc.getId()).isEqualTo(ref.getId());
+		assertThat(rc.getClientId()).isEqualTo(ref.getClientId());
+
+		if (ref.getClientIdIssuedAt() != null) {
+			// This can be set to default value
+			Instant inst = ref.getClientIdIssuedAt();
+			assertThat(rc.getClientIdIssuedAt()).isBetween(inst.minusMillis(1), inst.plusMillis(1));
+		}
+
+		assertThat(rc.getClientSecret()).isEqualTo(ref.getClientSecret());
+
+		if (ref.getClientSecretExpiresAt() != null) {
+			Instant inst = ref.getClientSecretExpiresAt();
+			assertThat(rc.getClientSecretExpiresAt()).isBetween(inst.minusMillis(1), inst.plusMillis(1));
+		} else {
+			assertThat(rc.getClientSecretExpiresAt()).isNull();
+		}
+
+		assertThat(rc.getClientName()).isEqualTo(ref.getClientName());
+		assertThat(rc.getClientAuthenticationMethods()).isEqualTo(ref.getClientAuthenticationMethods());
+		assertThat(rc.getAuthorizationGrantTypes()).isEqualTo(ref.getAuthorizationGrantTypes());
+		assertThat(rc.getRedirectUris()).isEqualTo(ref.getRedirectUris());
+		assertThat(rc.getScopes()).isEqualTo(ref.getScopes());
+		assertThat(rc.getClientSettings().requireUserConsent()).isEqualTo(ref.getClientSettings().requireUserConsent());
+		assertThat(rc.getClientSettings().requireProofKey()).isEqualTo(ref.getClientSettings().requireProofKey());
+		assertThat(rc.getTokenSettings().reuseRefreshTokens()).isEqualTo(ref.getTokenSettings().reuseRefreshTokens());
+		assertThat(rc.getTokenSettings().accessTokenTimeToLive()).isEqualTo(ref.getTokenSettings().accessTokenTimeToLive());
+		assertThat(rc.getTokenSettings().refreshTokenTimeToLive()).isEqualTo(ref.getTokenSettings().refreshTokenTimeToLive());
+	}
+
+	private static RegisteredClient createRegisteredClient() {
+		return createRegisteredClient("client-2", "client-id-2", "client-secret-2");
+	}
+
+
+	private static RegisteredClient createRegisteredClient(String id, String clientId, String clientSecret) {
+		// @formatter:off
+		return RegisteredClient.withId(id)
+				.clientId(clientId)
+				.clientSecret(clientSecret)
+				.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
+				.clientAuthenticationMethod(ClientAuthenticationMethod.BASIC)
+				.redirectUri("https://client.example.com")
+				.scope("scope1")
+				.build();
+		// @formatter:on
+	}
+
+}