Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
JpaOAuth2AuthorizationService for Spring Authorization Server
/*
* 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.
*/
import java.time.Instant;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
/**
* @author Steve Riesenberg
*/
@Entity
public class Authorization {
@Id
private String id;
private String registeredClientId;
private String principalName;
private String authorizationGrantType;
@Column(length = 4000)
private String attributes;
@Column(length = 500)
private String state;
@Column(length = 4000)
private String authorizationCode;
private Instant authorizationCodeIssuedAt;
private Instant authorizationCodeExpiresAt;
private String authorizationCodeMetadata;
@Column(length = 4000)
private String accessToken;
private Instant accessTokenIssuedAt;
private Instant accessTokenExpiresAt;
@Column(length = 2000)
private String accessTokenMetadata;
@Column(length = 1000)
private String accessTokenScopes;
@Column(length = 4000)
private String refreshToken;
private Instant refreshTokenIssuedAt;
private Instant refreshTokenExpiresAt;
@Column(length = 2000)
private String refreshTokenMetadata;
@Column(length = 4000)
private String idToken;
private Instant idTokenIssuedAt;
private Instant idTokenExpiresAt;
@Column(length = 2000)
private String idTokenMetadata;
@Column(length = 2000)
private String idTokenClaims;
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getRegisteredClientId() {
return registeredClientId;
}
public void setRegisteredClientId(String registeredClientId) {
this.registeredClientId = registeredClientId;
}
public String getPrincipalName() {
return principalName;
}
public void setPrincipalName(String principalName) {
this.principalName = principalName;
}
public String getAuthorizationGrantType() {
return authorizationGrantType;
}
public void setAuthorizationGrantType(String authorizationGrantType) {
this.authorizationGrantType = authorizationGrantType;
}
public String getAttributes() {
return attributes;
}
public void setAttributes(String attributes) {
this.attributes = attributes;
}
public String getState() {
return state;
}
public void setState(String state) {
this.state = state;
}
public String getAuthorizationCode() {
return authorizationCode;
}
public void setAuthorizationCode(String authorizationCode) {
this.authorizationCode = authorizationCode;
}
public Instant getAuthorizationCodeIssuedAt() {
return authorizationCodeIssuedAt;
}
public void setAuthorizationCodeIssuedAt(Instant authorizationCodeIssuedAt) {
this.authorizationCodeIssuedAt = authorizationCodeIssuedAt;
}
public Instant getAuthorizationCodeExpiresAt() {
return authorizationCodeExpiresAt;
}
public void setAuthorizationCodeExpiresAt(Instant authorizationCodeExpiresAt) {
this.authorizationCodeExpiresAt = authorizationCodeExpiresAt;
}
public String getAuthorizationCodeMetadata() {
return authorizationCodeMetadata;
}
public void setAuthorizationCodeMetadata(String authorizationCodeMetadata) {
this.authorizationCodeMetadata = authorizationCodeMetadata;
}
public String getAccessToken() {
return accessToken;
}
public void setAccessToken(String accessToken) {
this.accessToken = accessToken;
}
public Instant getAccessTokenIssuedAt() {
return accessTokenIssuedAt;
}
public void setAccessTokenIssuedAt(Instant accessTokenIssuedAt) {
this.accessTokenIssuedAt = accessTokenIssuedAt;
}
public Instant getAccessTokenExpiresAt() {
return accessTokenExpiresAt;
}
public void setAccessTokenExpiresAt(Instant accessTokenExpiresAt) {
this.accessTokenExpiresAt = accessTokenExpiresAt;
}
public String getAccessTokenMetadata() {
return accessTokenMetadata;
}
public void setAccessTokenMetadata(String accessTokenMetadata) {
this.accessTokenMetadata = accessTokenMetadata;
}
public String getAccessTokenScopes() {
return accessTokenScopes;
}
public void setAccessTokenScopes(String accessTokenScopes) {
this.accessTokenScopes = accessTokenScopes;
}
public String getRefreshToken() {
return refreshToken;
}
public void setRefreshToken(String refreshToken) {
this.refreshToken = refreshToken;
}
public Instant getRefreshTokenIssuedAt() {
return refreshTokenIssuedAt;
}
public void setRefreshTokenIssuedAt(Instant refreshTokenIssuedAt) {
this.refreshTokenIssuedAt = refreshTokenIssuedAt;
}
public Instant getRefreshTokenExpiresAt() {
return refreshTokenExpiresAt;
}
public void setRefreshTokenExpiresAt(Instant refreshTokenExpiresAt) {
this.refreshTokenExpiresAt = refreshTokenExpiresAt;
}
public String getRefreshTokenMetadata() {
return refreshTokenMetadata;
}
public void setRefreshTokenMetadata(String refreshTokenMetadata) {
this.refreshTokenMetadata = refreshTokenMetadata;
}
public String getIdToken() {
return idToken;
}
public void setIdToken(String idToken) {
this.idToken = idToken;
}
public Instant getIdTokenIssuedAt() {
return idTokenIssuedAt;
}
public void setIdTokenIssuedAt(Instant idTokenIssuedAt) {
this.idTokenIssuedAt = idTokenIssuedAt;
}
public Instant getIdTokenExpiresAt() {
return idTokenExpiresAt;
}
public void setIdTokenExpiresAt(Instant idTokenExpiresAt) {
this.idTokenExpiresAt = idTokenExpiresAt;
}
public String getIdTokenMetadata() {
return idTokenMetadata;
}
public void setIdTokenMetadata(String idTokenMetadata) {
this.idTokenMetadata = idTokenMetadata;
}
public String getIdTokenClaims() {
return idTokenClaims;
}
public void setIdTokenClaims(String idTokenClaims) {
this.idTokenClaims = idTokenClaims;
}
}
/*
* 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.
*/
import java.util.Optional;
import org.apache.tomcat.util.http.parser.Authorization;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
/**
* @author Steve Riesenberg
*/
@Repository
public interface AuthorizationRepository extends JpaRepository<Authorization, String> {
Optional<Authorization> findByState(String state);
Optional<Authorization> findByAuthorizationCode(String authorizationCode);
Optional<Authorization> findByAccessToken(String accessToken);
Optional<Authorization> findByRefreshToken(String refreshToken);
@Query("select a from Authorization a where a.state = :token" +
" or a.authorizationCode = :token" +
" or a.accessToken = :token" +
" or a.refreshToken = :token"
)
Optional<sample.jpa.Authorization> findByStateOrAuthorizationCodeOrAccessTokenOrRefreshToken(@Param("token") String token);
}
/*
* 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.
*/
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Consumer;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.Module;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.tomcat.util.http.parser.Authorization;
import sample.jpa.AuthorizationRepository;
import org.springframework.dao.DataRetrievalFailureException;
import org.springframework.security.jackson2.SecurityJackson2Modules;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2AuthorizationCode;
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
import org.springframework.security.oauth2.core.OAuth2Token;
import org.springframework.security.oauth2.core.OAuth2TokenType;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.jackson2.OAuth2AuthorizationServerJackson2Module;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* @author Steve Riesenberg
*/
@Component
public class JpaOAuth2AuthorizationService implements OAuth2AuthorizationService {
private final AuthorizationRepository authorizationRepository;
private final RegisteredClientRepository registeredClientRepository;
private final ObjectMapper objectMapper = new ObjectMapper();
public JpaOAuth2AuthorizationService(AuthorizationRepository authorizationRepository, RegisteredClientRepository registeredClientRepository) {
Assert.notNull(authorizationRepository, "authorizationRepository cannot be null");
Assert.notNull(registeredClientRepository, "registeredClientRepository cannot be null");
this.authorizationRepository = authorizationRepository;
this.registeredClientRepository = registeredClientRepository;
ClassLoader classLoader = JpaOAuth2AuthorizationService.class.getClassLoader();
List<Module> securityModules = SecurityJackson2Modules.getModules(classLoader);
this.objectMapper.registerModules(securityModules);
this.objectMapper.registerModule(new OAuth2AuthorizationServerJackson2Module());
}
@Override
public void save(OAuth2Authorization authorization) {
Assert.notNull(authorization, "authorization cannot be null");
this.authorizationRepository.save(toEntity(authorization));
}
@Override
public void remove(OAuth2Authorization authorization) {
Assert.notNull(authorization, "authorization cannot be null");
this.authorizationRepository.deleteById(authorization.getId());
}
@Override
public OAuth2Authorization findById(String id) {
Assert.hasText(id, "id cannot be empty");
return this.authorizationRepository.findById(id).map(this::toObject).orElse(null);
}
@Override
public OAuth2Authorization findByToken(String token, OAuth2TokenType tokenType) {
Assert.hasText(token, "token cannot be empty");
Optional<Authorization> result;
if (tokenType == null) {
result = this.authorizationRepository.findByStateOrAuthorizationCodeOrAccessTokenOrRefreshToken(token);
} else if (OAuth2ParameterNames.STATE.equals(tokenType.getValue())) {
result = this.authorizationRepository.findByState(token);
} else if (OAuth2ParameterNames.CODE.equals(tokenType.getValue())) {
result = this.authorizationRepository.findByAuthorizationCode(token);
} else if (OAuth2ParameterNames.ACCESS_TOKEN.equals(tokenType.getValue())) {
result = this.authorizationRepository.findByAccessToken(token);
} else if (OAuth2ParameterNames.REFRESH_TOKEN.equals(tokenType.getValue())) {
result = this.authorizationRepository.findByRefreshToken(token);
} else {
result = Optional.empty();
}
return result.map(this::toObject).orElse(null);
}
private OAuth2Authorization toObject(Authorization entity) {
RegisteredClient registeredClient = this.registeredClientRepository.findById(entity.getRegisteredClientId());
if (registeredClient == null) {
throw new DataRetrievalFailureException(
"The RegisteredClient with id '" + entity.getRegisteredClientId() + "' was not found in the RegisteredClientRepository.");
}
OAuth2Authorization.Builder builder = OAuth2Authorization.withRegisteredClient(registeredClient)
.id(entity.getId())
.principalName(entity.getPrincipalName())
.authorizationGrantType(resolveAuthorizationGrantType(entity.getAuthorizationGrantType()))
.attributes(attributes -> attributes.putAll(parseMap(entity.getAttributes())));
if (entity.getState() != null) {
builder.attribute(OAuth2ParameterNames.STATE, entity.getState());
}
if (entity.getAuthorizationCode() != null) {
OAuth2AuthorizationCode authorizationCode = new OAuth2AuthorizationCode(
entity.getAuthorizationCode(),
entity.getAuthorizationCodeIssuedAt(),
entity.getAuthorizationCodeExpiresAt());
builder.token(authorizationCode, metadata -> metadata.putAll(parseMap(entity.getAuthorizationCodeMetadata())));
}
if (entity.getAccessToken() != null) {
OAuth2AccessToken accessToken = new OAuth2AccessToken(
OAuth2AccessToken.TokenType.BEARER,
entity.getAccessToken(),
entity.getAccessTokenIssuedAt(),
entity.getAccessTokenExpiresAt());
builder.token(accessToken, metadata -> metadata.putAll(parseMap(entity.getAccessTokenMetadata())));
}
if (entity.getRefreshToken() != null) {
OAuth2RefreshToken refreshToken = new OAuth2RefreshToken(
entity.getRefreshToken(),
entity.getRefreshTokenIssuedAt(),
entity.getRefreshTokenExpiresAt());
builder.token(refreshToken, metadata -> metadata.putAll(parseMap(entity.getRefreshTokenMetadata())));
}
if (entity.getIdToken() != null) {
OidcIdToken idToken = new OidcIdToken(
entity.getIdToken(),
entity.getIdTokenIssuedAt(),
entity.getIdTokenExpiresAt(),
parseMap(entity.getIdTokenClaims()));
builder.token(idToken, metadata -> metadata.putAll(parseMap(entity.getIdTokenMetadata())));
}
return builder.build();
}
private Authorization toEntity(OAuth2Authorization authorization) {
Authorization entity = new Authorization();
entity.setId(authorization.getId());
entity.setRegisteredClientId(authorization.getRegisteredClientId());
entity.setPrincipalName(authorization.getPrincipalName());
entity.setAuthorizationGrantType(authorization.getAuthorizationGrantType().getValue());
entity.setAttributes(writeMap(authorization.getAttributes()));
entity.setState(authorization.getAttribute(OAuth2ParameterNames.STATE));
OAuth2Authorization.Token<OAuth2AuthorizationCode> authorizationCode =
authorization.getToken(OAuth2AuthorizationCode.class);
setTokenValues(
authorizationCode,
entity::setAuthorizationCode,
entity::setAuthorizationCodeIssuedAt,
entity::setAuthorizationCodeExpiresAt,
entity::setAuthorizationCodeMetadata
);
OAuth2Authorization.Token<OAuth2AccessToken> accessToken =
authorization.getToken(OAuth2AccessToken.class);
setTokenValues(
accessToken,
entity::setAccessToken,
entity::setAccessTokenIssuedAt,
entity::setAccessTokenExpiresAt,
entity::setAccessTokenMetadata
);
if (accessToken != null && accessToken.getToken().getScopes() != null) {
entity.setAccessTokenScopes(StringUtils.collectionToDelimitedString(accessToken.getToken().getScopes(), ","));
}
OAuth2Authorization.Token<OAuth2RefreshToken> refreshToken =
authorization.getToken(OAuth2RefreshToken.class);
setTokenValues(
refreshToken,
entity::setRefreshToken,
entity::setRefreshTokenIssuedAt,
entity::setRefreshTokenExpiresAt,
entity::setRefreshTokenMetadata
);
OAuth2Authorization.Token<OidcIdToken> oidcIdToken =
authorization.getToken(OidcIdToken.class);
setTokenValues(
oidcIdToken,
entity::setIdToken,
entity::setIdTokenIssuedAt,
entity::setIdTokenExpiresAt,
entity::setIdTokenMetadata
);
if (oidcIdToken != null) {
entity.setIdTokenClaims(writeMap(oidcIdToken.getClaims()));
}
return entity;
}
private void setTokenValues(
OAuth2Authorization.Token<?> token,
Consumer<String> tokenValueConsumer,
Consumer<Instant> issuedAtConsumer,
Consumer<Instant> expiresAtConsumer,
Consumer<String> metadataConsumer) {
if (token != null) {
OAuth2Token oAuth2Token = token.getToken();
tokenValueConsumer.accept(oAuth2Token.getTokenValue());
issuedAtConsumer.accept(oAuth2Token.getIssuedAt());
expiresAtConsumer.accept(oAuth2Token.getExpiresAt());
metadataConsumer.accept(writeMap(token.getMetadata()));
}
}
private Map<String, Object> parseMap(String data) {
try {
return this.objectMapper.readValue(data, new TypeReference<>() {
});
} catch (Exception ex) {
throw new IllegalArgumentException(ex.getMessage(), ex);
}
}
private String writeMap(Map<String, Object> metadata) {
try {
return this.objectMapper.writeValueAsString(metadata);
} catch (Exception ex) {
throw new IllegalArgumentException(ex.getMessage(), ex);
}
}
private static AuthorizationGrantType resolveAuthorizationGrantType(String authorizationGrantType) {
if (AuthorizationGrantType.AUTHORIZATION_CODE.getValue().equals(authorizationGrantType)) {
return AuthorizationGrantType.AUTHORIZATION_CODE;
} else if (AuthorizationGrantType.CLIENT_CREDENTIALS.getValue().equals(authorizationGrantType)) {
return AuthorizationGrantType.CLIENT_CREDENTIALS;
} else if (AuthorizationGrantType.REFRESH_TOKEN.getValue().equals(authorizationGrantType)) {
return AuthorizationGrantType.REFRESH_TOKEN;
}
return new AuthorizationGrantType(authorizationGrantType); // Custom authorization grant type
}
}
@Anthony00107
Copy link

Anthony00107 commented Sep 28, 2021

Download this

@mbarringer
Copy link

mbarringer commented Nov 4, 2021

@sjohnr I've used this to some degree of success with 0.2.0, but sometimes when getting an access token, I get a 500 error:

java.lang.IllegalArgumentException: The class with org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken and name of org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken is not in the allowlist. If you believe this class is safe to deserialize, please provide an explicit mapping using Jackson annotations or by providing a Mixin. If the serialization is only done by a trusted source, you can also enable default typing. See spring-projects/spring-security#4370 for details

It's happening when deserializing with this.objectMapper.readValue(data, new TypeReference<>() in parseMap(). Do I need to write a mixin for it? Activating default typing didn't work.

@sjohnr
Copy link
Author

sjohnr commented Nov 4, 2021

Yeah, I believe you will need to add a mixin. Are you using client_credentials?

@mbarringer
Copy link

mbarringer commented Nov 4, 2021

No, it was authorization_code. I tried adding several mixins and kept hitting errors, and eventually discovered it has nothing to do with your gist - it also happens with the standard JdbcOAuth2AuthenticationService with H2.

It seems to happen when the same session cookie is used with the calls to the authenticate endpoint, followed by a (successful) call to token followed by a (successful) call to authenticate followed by a failed call to token. I don't know for certain that's why it's happening, but the easiest workaround seems to be to get a authentication code with a different session cookie as the one for the token. At least, doing it that way didn't trigger that exception, but I ran out of time to investigate.

@sjohnr
Copy link
Author

sjohnr commented Nov 4, 2021

Ok. I was going to ask, so thanks for confirming it happens with jdbc/h2 as well. When you say authenticate, do you mean the /oauth2/authorize endpoint? Also, are you calling the token endpoint with a session? Are you connecting via a SPA as a public client?

@mbarringer
Copy link

mbarringer commented Nov 4, 2021

Sorry, yes, I meant /oauth2/authorize.

I was originally testing whether a resource server API works with a Spring Authorization Server's authorization code flow using an API tool, Paw, which has built-in OAuth2 auth support, and which sends the same JSESSION cookie along with repeated OAuth2 requests, apparently not changing it until the server gives it a new one. I could use Paw to get a token from /oauth2/token once, after the auth server first started, but after that it would return a 500 error with that exception.

Wireshark showed what I tried to describe above: It goes to /oauth2/authorize (login page, then receive authorization code ) -> /oauth2/token (success, a JWT) -> I click "Get Access Token" again -> /oauth2/authorize (no login page, just an authorization code) -> /oauth2/token (500, despite the valid authorization_code). Refresh tokens work every time in getting a new access token, it's only an issue with the authorization_code grant on the /oauth2/token endpoint.

I then used https://www.oidcdebugger.com to get the auth code and plugged that directly into a POST /oauth2/token. With a different JSESSION cookie than the one the browser uses, I get a JWT from the endpoint every time. When I then use the JSESSION cookie from the browser, which was sent along to the /oauth2/authorize endpoint, in my POST call, I get a 500 with the Jackson exception.

I don't know if that's due to a mistake on my end, or even if the JSESSION is the culprit, but I can reliably reproduce it.

@sjohnr
Copy link
Author

sjohnr commented Nov 4, 2021

Thanks @mbarringer. That sounds like an issue that could be reproduced in the existing samples. Would you mind reporting that on the issue tracker?

@mbarringer
Copy link

mbarringer commented Nov 5, 2021

Sure, I hope I described it well enough to reproduce: spring-projects/spring-authorization-server#482

@bnutzer
Copy link

bnutzer commented Nov 23, 2021

Beautiful! Thanks.

Do you have any intention to take the JpaOAuth2AuthorizationService upstream?

Can you confirm that this code is licensed under the spring/Apache license or free to use under whatever license we want to?

@sjohnr
Copy link
Author

sjohnr commented Nov 23, 2021

@bnutzer that's a good question. I will add a license for these since the intention is that they will become part of the reference documentation for the project. They will not be supported or part of the distribution, as they are just examples.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment