I have been using the previous version of Spring Security OAuth 2 for several years. Now, with the upgrade to Spring Boot 3.1.0, I am moving to the new packages. In particular, I am implementing Spring Authorization Server.
The old OAuth2 package had a DefaultTokenServices
that took a simple signing key to sign the JWT tokens. However, Spring Auth Server appears much more complex.
I have tens of thousands of users out there now with "old" JWT tokens. When I roll out this upgrade I do not want them all to have to re-authorize.
How can I configure Spring Authorization Server to make it compatible with the old token format? (I realize this is more of a Resource Server question, but Auth Server will have to product JWT tokens that are compatible with Resource Server which should be compatible with legacy...)
This turned out to be involved. The new Nimbus packages, when using a symmetric key (which my legacy system does) will not work with key lengths of less than 256 characters - but the old key is much shorter than that. So in order to keep legacy compatibility, I had to re-use the old mechanisms to authenticate the tokens already out there in the wild.
First, I had to re-add the old Spring Security OAuth (I thought about just extracting the necessary classes, thinking it can't be that hard, but you need about 100).
<dependency>
<groupId>org.springframework.security.oauth</groupId>
<artifactId>spring-security-oauth2</artifactId>
<version>2.5.2.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-jwt</artifactId>
<version>1.1.1.RELEASE</version>
</dependency>
Now I need an AuthenticationProvider
that can handle the legacy-style tokens. The new-style class is JwtAuthenticationProvider
, while the old-style class was OAuth2AuthenticationManager
. So somehow I had to bridge that gap.
I created this, which is a mixture of the appropriate code of both:
public class LegacyOauthAuthenticationProvider implements AuthenticationProvider
{
private final Log logger = LogFactory.getLog(getClass());
private DefaultTokenServices tokenServices;
private ClientDetailsService clientDetailsService;
private Converter<Jwt, ? extends AbstractAuthenticationToken> jwtAuthenticationConverter = new JwtAuthenticationConverter();
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException
{
BearerTokenAuthenticationToken bearer = (BearerTokenAuthenticationToken) authentication;
String token = bearer.getToken();
OAuth2Authentication oauth2Auth;
OAuth2AccessToken accessToken;
try
{
oauth2Auth = tokenServices.loadAuthentication(token);
accessToken = tokenServices.readAccessToken(token);
if(oauth2Auth == null)
{
logger.warn("Invalid token");
return null;
}
if(accessToken == null)
{
logger.warn("We lack the information to create a JWT object");
return null;
}
}
catch(InvalidTokenException e)
{
// This means it's a new-style token, don't blow up.
logger.debug("New style OAuth token detected", e);
return null;
}
checkClientDetails(oauth2Auth);
String tokenValue = token;
Instant issuedAt = null;
Instant expiresAt = Instant.now().plusSeconds(10000);
// the headers are lost, but this is what they were
Map<String, Object> headers = Map.of("alg", "HS256", "typ", "JWT");
Map<String, Object> claims = accessToken.getAdditionalInformation();
// Missing scope
claims.put("scope", accessToken.getScope());
Jwt jwt = new Jwt(tokenValue, issuedAt, expiresAt, headers, claims);
AbstractAuthenticationToken aat = this.jwtAuthenticationConverter.convert(jwt);
if (aat.getDetails() == null)
{
aat.setDetails(bearer.getDetails());
}
this.logger.debug("Authenticated JWT token");
return aat;
}
@Override
public boolean supports(Class<?> authentication)
{
return BearerTokenAuthenticationToken.class.isAssignableFrom(authentication);
}
private void checkClientDetails(OAuth2Authentication auth)
{
if(clientDetailsService != null)
{
ClientDetails client;
try
{
client = clientDetailsService.loadClientByClientId(auth.getOAuth2Request().getClientId());
}
catch(ClientRegistrationException e)
{
throw new OAuth2AccessDeniedException("Invalid token contains invalid client id");
}
Set<String> allowed = client.getScope();
for(String scope : auth.getOAuth2Request().getScope())
{
if(!allowed.contains(scope))
{
throw new OAuth2AccessDeniedException(
"Invalid token contains disallowed scope for this client");
}
}
}
}
public void setTokenServices(DefaultTokenServices tokenServices)
{
this.tokenServices = tokenServices;
}
public void setJwtAuthenticationConverter(Converter<Jwt, ? extends AbstractAuthenticationToken> jwtAuthenticationConverter)
{
this.jwtAuthenticationConverter = jwtAuthenticationConverter;
}
public void setClientDetailsService(ClientDetailsService clientDetailsService)
{
this.clientDetailsService = clientDetailsService;
}
}
This is added to the main AuthenticationManager
as a provider.
Config for this guy is like this
@Bean
public DefaultTokenServices legacyTokenServices()
{
DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
defaultTokenServices.setTokenStore(legacyTokenStore());
defaultTokenServices.setTokenEnhancer(legacyJwtTokenEnhancer());
defaultTokenServices.setClientDetailsService(legacyClientDetailsService());
defaultTokenServices.setSupportRefreshToken(true);
return defaultTokenServices;
}
@Bean
public TokenStore legacyTokenStore()
{
return new JwtTokenStore(legacyJwtTokenEnhancer());
}
@Bean
public JwtAccessTokenConverter legacyJwtTokenEnhancer()
{
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey(keySource.legacySigningKeyString());
return converter;
}
@Bean
public ClientDetailsService legacyClientDetailsService()
{
JdbcClientDetailsService svc = new JdbcClientDetailsService(datasource);
svc.setPasswordEncoder(passwordEncoder);
return svc;
}
// We need this because the principal claim is different too
@Bean
public JwtAuthenticationConverter legacyJwtAuthenticationConverter()
{
RoleMemberJwtGrantedAuthenticationConverter grantedAuthoritiesConverter = new RoleMemberJwtGrantedAuthenticationConverter();
grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
jwtAuthenticationConverter.setPrincipalClaimName("user_name");
return jwtAuthenticationConverter;
}
@Bean
public LegacyOauthAuthenticationProvider legacyJwtAuthenticationProvider()
{
LegacyOauthAuthenticationProvider legacyOauthAuthenticationProvider = new LegacyOauthAuthenticationProvider();
legacyOauthAuthenticationProvider.setJwtAuthenticationConverter(legacyJwtAuthenticationConverter());
legacyOauthAuthenticationProvider.setTokenServices(legacyTokenServices());
legacyOauthAuthenticationProvider.setClientDetailsService(legacyClientDetailsService());
return legacyOauthAuthenticationProvider;
}
With all of this, both the legacy- and the new-style tokens are recoginized. Be sure to put the legacy provider in front of the new provider - the new provider will blow up if it finds a JWT token that it doesn't like. That's why this legacy adapter swallows the InvalidTokenException
. If it's truly invalid, like some kind of attack, the next provider will puke it up.