Search code examples
spring-authorization-server

How to configure Spring Authorization Server to be Backwards Compatible with old Spring Security OAuth2?


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...)


Solution

  • 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.