Skip to content

Commit de8b558

Browse files
committed
Add JDBC implementation of OAuth2AuthorizedClientService
Fixes gh-7655
1 parent a51a202 commit de8b558

File tree

5 files changed

+815
-0
lines changed

5 files changed

+815
-0
lines changed

oauth2/oauth2-client/spring-security-oauth2-client.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ dependencies {
1212
optional 'org.springframework:spring-webflux'
1313
optional 'com.fasterxml.jackson.core:jackson-databind'
1414
optional 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
15+
optional 'org.springframework:spring-jdbc'
1516

1617
testCompile project(path: ':spring-security-oauth2-core', configuration: 'tests')
1718
testCompile project(path: ':spring-security-oauth2-jose', configuration: 'tests')
@@ -22,5 +23,7 @@ dependencies {
2223
testCompile 'io.projectreactor.tools:blockhound'
2324
testCompile 'org.skyscreamer:jsonassert'
2425

26+
testRuntime 'org.hsqldb:hsqldb'
27+
2528
provided 'javax.servlet:javax.servlet-api'
2629
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
/*
2+
* Copyright 2002-2020 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.security.oauth2.client;
17+
18+
import org.springframework.dao.DataRetrievalFailureException;
19+
import org.springframework.jdbc.core.ArgumentPreparedStatementSetter;
20+
import org.springframework.jdbc.core.JdbcOperations;
21+
import org.springframework.jdbc.core.PreparedStatementSetter;
22+
import org.springframework.jdbc.core.RowMapper;
23+
import org.springframework.jdbc.core.SqlParameterValue;
24+
import org.springframework.security.core.Authentication;
25+
import org.springframework.security.oauth2.client.registration.ClientRegistration;
26+
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
27+
import org.springframework.security.oauth2.core.OAuth2AccessToken;
28+
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
29+
import org.springframework.util.Assert;
30+
import org.springframework.util.CollectionUtils;
31+
import org.springframework.util.StringUtils;
32+
33+
import java.nio.charset.StandardCharsets;
34+
import java.sql.ResultSet;
35+
import java.sql.SQLException;
36+
import java.sql.Timestamp;
37+
import java.sql.Types;
38+
import java.time.Instant;
39+
import java.util.ArrayList;
40+
import java.util.Collections;
41+
import java.util.List;
42+
import java.util.Set;
43+
import java.util.function.Function;
44+
45+
/**
46+
* A JDBC implementation of an {@link OAuth2AuthorizedClientService}
47+
* that uses a {@link JdbcOperations} for {@link OAuth2AuthorizedClient} persistence.
48+
*
49+
* <p>
50+
* <b>NOTE:</b> This {@code OAuth2AuthorizedClientService} depends on the table definition
51+
* described in "classpath:org/springframework/security/oauth2/client/oauth2-client-schema.sql"
52+
* and therefore MUST be defined in the database schema.
53+
*
54+
* @author Joe Grandja
55+
* @since 5.3
56+
* @see OAuth2AuthorizedClientService
57+
* @see OAuth2AuthorizedClient
58+
* @see JdbcOperations
59+
* @see RowMapper
60+
*/
61+
public class JdbcOAuth2AuthorizedClientService implements OAuth2AuthorizedClientService {
62+
private static final String COLUMN_NAMES =
63+
"client_registration_id, " +
64+
"principal_name, " +
65+
"access_token_type, " +
66+
"access_token_value, " +
67+
"access_token_issued_at, " +
68+
"access_token_expires_at, " +
69+
"access_token_scopes, " +
70+
"refresh_token_value, " +
71+
"refresh_token_issued_at";
72+
private static final String TABLE_NAME = "oauth2_authorized_client";
73+
private static final String PK_FILTER = "client_registration_id = ? AND principal_name = ?";
74+
private static final String LOAD_AUTHORIZED_CLIENT_SQL = "SELECT " + COLUMN_NAMES +
75+
" FROM " + TABLE_NAME + " WHERE " + PK_FILTER;
76+
private static final String SAVE_AUTHORIZED_CLIENT_SQL = "INSERT INTO " + TABLE_NAME +
77+
" (" + COLUMN_NAMES + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
78+
private static final String REMOVE_AUTHORIZED_CLIENT_SQL = "DELETE FROM " + TABLE_NAME +
79+
" WHERE " + PK_FILTER;
80+
protected final JdbcOperations jdbcOperations;
81+
protected RowMapper<OAuth2AuthorizedClient> authorizedClientRowMapper;
82+
protected Function<OAuth2AuthorizedClientHolder, List<SqlParameterValue>> authorizedClientParametersMapper;
83+
84+
/**
85+
* Constructs a {@code JdbcOAuth2AuthorizedClientService} using the provided parameters.
86+
*
87+
* @param jdbcOperations the JDBC operations
88+
* @param clientRegistrationRepository the repository of client registrations
89+
*/
90+
public JdbcOAuth2AuthorizedClientService(
91+
JdbcOperations jdbcOperations, ClientRegistrationRepository clientRegistrationRepository) {
92+
93+
Assert.notNull(jdbcOperations, "jdbcOperations cannot be null");
94+
Assert.notNull(clientRegistrationRepository, "clientRegistrationRepository cannot be null");
95+
this.jdbcOperations = jdbcOperations;
96+
this.authorizedClientRowMapper = new OAuth2AuthorizedClientRowMapper(clientRegistrationRepository);
97+
this.authorizedClientParametersMapper = new OAuth2AuthorizedClientParametersMapper();
98+
}
99+
100+
@Override
101+
@SuppressWarnings("unchecked")
102+
public <T extends OAuth2AuthorizedClient> T loadAuthorizedClient(String clientRegistrationId, String principalName) {
103+
Assert.hasText(clientRegistrationId, "clientRegistrationId cannot be empty");
104+
Assert.hasText(principalName, "principalName cannot be empty");
105+
106+
SqlParameterValue[] parameters = new SqlParameterValue[] {
107+
new SqlParameterValue(Types.VARCHAR, clientRegistrationId),
108+
new SqlParameterValue(Types.VARCHAR, principalName)
109+
};
110+
PreparedStatementSetter pss = new ArgumentPreparedStatementSetter(parameters);
111+
112+
List<OAuth2AuthorizedClient> result = this.jdbcOperations.query(
113+
LOAD_AUTHORIZED_CLIENT_SQL, pss, this.authorizedClientRowMapper);
114+
115+
return !result.isEmpty() ? (T) result.get(0) : null;
116+
}
117+
118+
@Override
119+
public void saveAuthorizedClient(OAuth2AuthorizedClient authorizedClient, Authentication principal) {
120+
Assert.notNull(authorizedClient, "authorizedClient cannot be null");
121+
Assert.notNull(principal, "principal cannot be null");
122+
123+
List<SqlParameterValue> parameters = this.authorizedClientParametersMapper.apply(
124+
new OAuth2AuthorizedClientHolder(authorizedClient, principal));
125+
PreparedStatementSetter pss = new ArgumentPreparedStatementSetter(parameters.toArray());
126+
127+
this.jdbcOperations.update(SAVE_AUTHORIZED_CLIENT_SQL, pss);
128+
}
129+
130+
@Override
131+
public void removeAuthorizedClient(String clientRegistrationId, String principalName) {
132+
Assert.hasText(clientRegistrationId, "clientRegistrationId cannot be empty");
133+
Assert.hasText(principalName, "principalName cannot be empty");
134+
135+
SqlParameterValue[] parameters = new SqlParameterValue[] {
136+
new SqlParameterValue(Types.VARCHAR, clientRegistrationId),
137+
new SqlParameterValue(Types.VARCHAR, principalName)
138+
};
139+
PreparedStatementSetter pss = new ArgumentPreparedStatementSetter(parameters);
140+
141+
this.jdbcOperations.update(REMOVE_AUTHORIZED_CLIENT_SQL, pss);
142+
}
143+
144+
/**
145+
* Sets the {@link RowMapper} used for mapping the current row in {@code java.sql.ResultSet} to {@link OAuth2AuthorizedClient}.
146+
* The default is {@link OAuth2AuthorizedClientRowMapper}.
147+
*
148+
* @param authorizedClientRowMapper the {@link RowMapper} used for mapping the current row in {@code java.sql.ResultSet} to {@link OAuth2AuthorizedClient}
149+
*/
150+
public final void setAuthorizedClientRowMapper(RowMapper<OAuth2AuthorizedClient> authorizedClientRowMapper) {
151+
Assert.notNull(authorizedClientRowMapper, "authorizedClientRowMapper cannot be null");
152+
this.authorizedClientRowMapper = authorizedClientRowMapper;
153+
}
154+
155+
/**
156+
* Sets the {@code Function} used for mapping {@link OAuth2AuthorizedClientHolder} to a {@code List} of {@link SqlParameterValue}.
157+
* The default is {@link OAuth2AuthorizedClientParametersMapper}.
158+
*
159+
* @param authorizedClientParametersMapper the {@code Function} used for mapping {@link OAuth2AuthorizedClientHolder} to a {@code List} of {@link SqlParameterValue}
160+
*/
161+
public final void setAuthorizedClientParametersMapper(Function<OAuth2AuthorizedClientHolder, List<SqlParameterValue>> authorizedClientParametersMapper) {
162+
Assert.notNull(authorizedClientParametersMapper, "authorizedClientParametersMapper cannot be null");
163+
this.authorizedClientParametersMapper = authorizedClientParametersMapper;
164+
}
165+
166+
/**
167+
* The default {@link RowMapper} that maps the current row
168+
* in {@code java.sql.ResultSet} to {@link OAuth2AuthorizedClient}.
169+
*/
170+
public static class OAuth2AuthorizedClientRowMapper implements RowMapper<OAuth2AuthorizedClient> {
171+
protected final ClientRegistrationRepository clientRegistrationRepository;
172+
173+
public OAuth2AuthorizedClientRowMapper(ClientRegistrationRepository clientRegistrationRepository) {
174+
Assert.notNull(clientRegistrationRepository, "clientRegistrationRepository cannot be null");
175+
this.clientRegistrationRepository = clientRegistrationRepository;
176+
}
177+
178+
@Override
179+
public OAuth2AuthorizedClient mapRow(ResultSet rs, int rowNum) throws SQLException {
180+
String clientRegistrationId = rs.getString("client_registration_id");
181+
ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId(
182+
clientRegistrationId);
183+
if (clientRegistration == null) {
184+
throw new DataRetrievalFailureException("The ClientRegistration with id '" +
185+
clientRegistrationId + "' exists in the data source, " +
186+
"however, it was not found in the ClientRegistrationRepository.");
187+
}
188+
189+
OAuth2AccessToken.TokenType tokenType = null;
190+
if (OAuth2AccessToken.TokenType.BEARER.getValue().equalsIgnoreCase(
191+
rs.getString("access_token_type"))) {
192+
tokenType = OAuth2AccessToken.TokenType.BEARER;
193+
}
194+
String tokenValue = new String(rs.getBytes("access_token_value"), StandardCharsets.UTF_8);
195+
Instant issuedAt = rs.getTimestamp("access_token_issued_at").toInstant();
196+
Instant expiresAt = rs.getTimestamp("access_token_expires_at").toInstant();
197+
Set<String> scopes = Collections.emptySet();
198+
String accessTokenScopes = rs.getString("access_token_scopes");
199+
if (accessTokenScopes != null) {
200+
scopes = StringUtils.commaDelimitedListToSet(accessTokenScopes);
201+
}
202+
OAuth2AccessToken accessToken = new OAuth2AccessToken(
203+
tokenType, tokenValue, issuedAt, expiresAt, scopes);
204+
205+
OAuth2RefreshToken refreshToken = null;
206+
byte[] refreshTokenValue = rs.getBytes("refresh_token_value");
207+
if (refreshTokenValue != null) {
208+
tokenValue = new String(refreshTokenValue, StandardCharsets.UTF_8);
209+
issuedAt = null;
210+
Timestamp refreshTokenIssuedAt = rs.getTimestamp("refresh_token_issued_at");
211+
if (refreshTokenIssuedAt != null) {
212+
issuedAt = refreshTokenIssuedAt.toInstant();
213+
}
214+
refreshToken = new OAuth2RefreshToken(tokenValue, issuedAt);
215+
}
216+
217+
String principalName = rs.getString("principal_name");
218+
219+
return new OAuth2AuthorizedClient(
220+
clientRegistration, principalName, accessToken, refreshToken);
221+
}
222+
}
223+
224+
/**
225+
* The default {@code Function} that maps {@link OAuth2AuthorizedClientHolder}
226+
* to a {@code List} of {@link SqlParameterValue}.
227+
*/
228+
public static class OAuth2AuthorizedClientParametersMapper implements Function<OAuth2AuthorizedClientHolder, List<SqlParameterValue>> {
229+
230+
@Override
231+
public List<SqlParameterValue> apply(OAuth2AuthorizedClientHolder authorizedClientHolder) {
232+
OAuth2AuthorizedClient authorizedClient = authorizedClientHolder.getAuthorizedClient();
233+
Authentication principal = authorizedClientHolder.getPrincipal();
234+
ClientRegistration clientRegistration = authorizedClient.getClientRegistration();
235+
OAuth2AccessToken accessToken = authorizedClient.getAccessToken();
236+
OAuth2RefreshToken refreshToken = authorizedClient.getRefreshToken();
237+
238+
List<SqlParameterValue> parameters = new ArrayList<>();
239+
parameters.add(new SqlParameterValue(
240+
Types.VARCHAR, clientRegistration.getRegistrationId()));
241+
parameters.add(new SqlParameterValue(
242+
Types.VARCHAR, principal.getName()));
243+
parameters.add(new SqlParameterValue(
244+
Types.VARCHAR, accessToken.getTokenType().getValue()));
245+
parameters.add(new SqlParameterValue(
246+
Types.BLOB, accessToken.getTokenValue().getBytes(StandardCharsets.UTF_8)));
247+
parameters.add(new SqlParameterValue(
248+
Types.TIMESTAMP, Timestamp.from(accessToken.getIssuedAt())));
249+
parameters.add(new SqlParameterValue(
250+
Types.TIMESTAMP, Timestamp.from(accessToken.getExpiresAt())));
251+
String accessTokenScopes = null;
252+
if (!CollectionUtils.isEmpty(accessToken.getScopes())) {
253+
accessTokenScopes = StringUtils.collectionToDelimitedString(accessToken.getScopes(), ",");
254+
}
255+
parameters.add(new SqlParameterValue(
256+
Types.VARCHAR, accessTokenScopes));
257+
byte[] refreshTokenValue = null;
258+
Timestamp refreshTokenIssuedAt = null;
259+
if (refreshToken != null) {
260+
refreshTokenValue = refreshToken.getTokenValue().getBytes(StandardCharsets.UTF_8);
261+
if (refreshToken.getIssuedAt() != null) {
262+
refreshTokenIssuedAt = Timestamp.from(refreshToken.getIssuedAt());
263+
}
264+
}
265+
parameters.add(new SqlParameterValue(
266+
Types.BLOB, refreshTokenValue));
267+
parameters.add(new SqlParameterValue(
268+
Types.TIMESTAMP, refreshTokenIssuedAt));
269+
270+
return parameters;
271+
}
272+
}
273+
274+
/**
275+
* A holder for an {@link OAuth2AuthorizedClient} and End-User {@link Authentication} (Resource Owner).
276+
*/
277+
public static final class OAuth2AuthorizedClientHolder {
278+
private final OAuth2AuthorizedClient authorizedClient;
279+
private final Authentication principal;
280+
281+
/**
282+
* Constructs an {@code OAuth2AuthorizedClientHolder} using the provided parameters.
283+
*
284+
* @param authorizedClient the authorized client
285+
* @param principal the End-User {@link Authentication} (Resource Owner)
286+
*/
287+
public OAuth2AuthorizedClientHolder(OAuth2AuthorizedClient authorizedClient, Authentication principal) {
288+
Assert.notNull(authorizedClient, "authorizedClient cannot be null");
289+
Assert.notNull(principal, "principal cannot be null");
290+
this.authorizedClient = authorizedClient;
291+
this.principal = principal;
292+
}
293+
294+
/**
295+
* Returns the {@link OAuth2AuthorizedClient}.
296+
*
297+
* @return the {@link OAuth2AuthorizedClient}
298+
*/
299+
public OAuth2AuthorizedClient getAuthorizedClient() {
300+
return this.authorizedClient;
301+
}
302+
303+
/**
304+
* Returns the End-User {@link Authentication} (Resource Owner).
305+
*
306+
* @return the End-User {@link Authentication} (Resource Owner)
307+
*/
308+
public Authentication getPrincipal() {
309+
return this.principal;
310+
}
311+
}
312+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
CREATE TABLE oauth2_authorized_client (
2+
client_registration_id varchar(100) NOT NULL,
3+
principal_name varchar(200) NOT NULL,
4+
access_token_type varchar(100) NOT NULL,
5+
access_token_value blob NOT NULL,
6+
access_token_issued_at timestamp NOT NULL,
7+
access_token_expires_at timestamp NOT NULL,
8+
access_token_scopes varchar(1000) DEFAULT NULL,
9+
refresh_token_value blob DEFAULT NULL,
10+
refresh_token_issued_at timestamp DEFAULT NULL,
11+
created_at timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
12+
PRIMARY KEY (client_registration_id, principal_name)
13+
);

0 commit comments

Comments
 (0)