Overriding Spring JDBC session serialization

December 05, 2022

Recently I came across an interesting problem that was causing session deserialization failures after upgrading the Spring Security dependency to the latest version in one of our applications.

The error that I encountered was the following:

java.io.InvalidClassException: org.springframework.security.core.context.SecurityContextImpl;
local class incompatible: stream classdesc serialVersionUID = 570, local class serialVersionUID = 560

After some digging I found out that Spring Security is not intended to be serialized between versions. The reason comes down to failures in Spring's own development environment and how they pass around the authentication token in the RMI.

In order to fix the error, they are suggesting two possible scenarios:

  1. Invalidate active sessions after each version bump: this is most likely the easiest fix. We need to delete the sessions from the database or change the expiry timestamps, so that new ones can be created. Usually this means forcing users to log out on every security upgrade and that's not really a solution, but rather a temporary fix to the problem.
  2. Override default session serialization: this is the preferred solution. We want to override the default behaviour and save the session as a JSON blob so that we are not dependent on the serialVersionUID changes which is the main cause of the issue.

Implementation

Before diving into the implementation, we need to understand how Spring handles the session serialization and deserialization.

If we look closely at Spring's JdbcHttpSessionConfiguration class, we can see a bean called sessionRepository. This is an instance of JdbcIndexedSessionRepository which is used to store sessions in a relational database. However, the important thing here is how the repository's conversionService is configured.

package org.springframework.session.jdbc.config.annotation.web.http;

class JdbcSessionConfiguration {

  @Bean
  public JdbcIndexedSessionRepository sessionRepository() {
    ...
    if (this.springSessionConversionService != null) {
      sessionRepository.setConversionService(this.springSessionConversionService);
    } else if (this.conversionService != null) {
      sessionRepository.setConversionService(this.conversionService);
    } else {
      sessionRepository.setConversionService(createConversionServiceWithBeanClassLoader(this.classLoader));
    }
    ...
  }

  private static GenericConversionService createConversionServiceWithBeanClassLoader(ClassLoader classLoader) {
    GenericConversionService conversionService = new GenericConversionService();
    conversionService.addConverter(Object.class, byte[].class, new SerializingConverter());
    conversionService.addConverter(byte[].class, Object.class, new DeserializingConverter(classLoader));
    return conversionService;
  }

We can see that, if neither springSessionConversionService nor conversionService is defined, it falls back to the default serialization logic, which converts the session object into byte stream and back.

In order to override the default behaviour, all we have to do is define a bean called springSessionConversionService in our codebase.

@Bean
public ConversionService springSessionConversionService() {
  return new GenericConversionService();
}

Then, next time we boot up our application, Spring will automatically pick up the bean from the application context and plug it into the session repository.

The next step is to convert the object into JSON and back using the Jackson library. Luckily Spring has a great Jackson support and provides all the necessary modules to do so.

var mapper = new ObjectMapper().registerModules(
  SecurityJackson2Modules.getModules(getClass().getClassLoader()));

After that, we just need to choose what type of conversion logic should be used by defining the two necessary converters.

var conversionService = new GenericConversionService();
conversionService.addConverter(Object.class, byte[].class, source -> {
  try {
    return mapper.writeValueAsBytes(source);
  } catch (IOException e) {
    throw new RuntimeException("Unable to serialize Spring Session.", e);
  }
});
conversionService.addConverter(byte[].class, Object.class, source -> {
  try {
    return mapper.readValue(source, Object.class);
  } catch (IOException e) {
    throw new RuntimeException("Unable to deserialize Spring Session.", e);
  }
});
return conversionService;

The converter that will be used depends on the source and target types. For the serialization we are converting the object into a JSON byte array, whereas for the deserialization it's the opposite.

If you're curious, the serialized value will be stored in the database as a binary string and decoding it into text gives us something like this:

{
  "@class": "org.springframework.security.core.context.SecurityContextImpl",
  "authentication": {
    "@class": "org.springframework.security.authentication.UsernamePasswordAuthenticationToken",
    "authorities": ["java.util.Collections$UnmodifiableRandomAccessList", []],
    "details": {
      "@class": "org.springframework.security.web.authentication.WebAuthenticationDetails",
      "sessionId": "429d30d8-254f-4f17-a12c-13e32b1609bf"
      ...
    },
    "authenticated": true,
    "principal": {
      "@class": "org.springframework.security.core.userdetails.User",
      ...
    },
  }
}

The modules that we provided for the mapper add all the necessary meta-properties for the classes that can be serialized. However, sometimes that might not be enough. There can be a case where we want to add some additional details to the security principal and this causes a problem, because Jackson doesn't know how to deserialize the object back anymore. If that happens, we need to add a special Jackson annotation to those classes.

@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS)

Deploying to production

The above implementation will solve the problem for future Spring Security upgrades. However, once we are going to deploy these new changes into production, we'll be facing another issue:

com.fasterxml.jackson.core.JsonParseException: Unexpected character ('¬' (code 172)):
expected a valid value (JSON String, Number, Array, Object or token 'null', 'true' or 'false')

This happens, because the previously stored sessions were serialized using the old logic and now we want to deserialize them back using the new logic. In order to fix this, we need to handle the exception and use the previous default deserialization logic for older sessions until they expire.

var defaultDeserializer = new DeserializingConverter();
conversionService.addConverter(byte[].class, Object.class, source -> {
  try {
    return objectMapper.readValue(source, Object.class);
  } catch (IOException e) {
    LOG.warn("Falling back to default session deserialization.");
    return defaultDeserializer.convert(source);
  }
});

This is only needed until the changes have been populated into the production environment.

That's all, folks!

I hope this article has helped you solve or prevent problems related to Spring Security upgrades. Did I miss anything or do you see something you'd change? Reach out to me via e-mail.

Full implementation can be found on my github repository.