View Javadoc
1   /*
2    * Copyright (C) 2025 B3Partners B.V.
3    *
4    * SPDX-License-Identifier: MIT
5    */
6   
7   package org.tailormap.api.configuration;
8   
9   import com.fasterxml.jackson.annotation.JsonTypeInfo;
10  import java.lang.invoke.MethodHandles;
11  import java.nio.charset.StandardCharsets;
12  import org.jspecify.annotations.NonNull;
13  import org.slf4j.Logger;
14  import org.slf4j.LoggerFactory;
15  import org.springframework.beans.factory.BeanClassLoaderAware;
16  import org.springframework.context.annotation.Bean;
17  import org.springframework.context.annotation.Configuration;
18  import org.springframework.core.convert.ConversionService;
19  import org.springframework.core.convert.support.GenericConversionService;
20  import org.springframework.security.jackson.SecurityJacksonModules;
21  import org.springframework.session.config.SessionRepositoryCustomizer;
22  import org.springframework.session.jdbc.JdbcIndexedSessionRepository;
23  import tools.jackson.core.JacksonException;
24  import tools.jackson.core.StreamReadFeature;
25  import tools.jackson.databind.DefaultTyping;
26  import tools.jackson.databind.SerializationFeature;
27  import tools.jackson.databind.json.JsonMapper;
28  import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
29  
30  @Configuration(proxyBeanMethods = false)
31  public class JdbcSessionConfiguration implements BeanClassLoaderAware {
32    private static final Logger logger =
33        LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
34  
35    private static final String CREATE_SESSION_ATTRIBUTE_QUERY = """
36  INSERT INTO %TABLE_NAME%_ATTRIBUTES (SESSION_PRIMARY_ID, ATTRIBUTE_NAME, ATTRIBUTE_BYTES)
37  VALUES (?, ?, convert_from(?, 'UTF8')::jsonb)
38  """;
39  
40    private static final String UPDATE_SESSION_ATTRIBUTE_QUERY = """
41  UPDATE %TABLE_NAME%_ATTRIBUTES
42  SET ATTRIBUTE_BYTES = encode(?, 'escape')::jsonb
43  WHERE SESSION_PRIMARY_ID = ?
44  AND ATTRIBUTE_NAME = ?
45  """;
46  
47    private ClassLoader classLoader;
48  
49    @Bean
50    SessionRepositoryCustomizer<JdbcIndexedSessionRepository> customizer() {
51      return (sessionRepository) -> {
52        sessionRepository.setCreateSessionAttributeQuery(CREATE_SESSION_ATTRIBUTE_QUERY);
53        sessionRepository.setUpdateSessionAttributeQuery(UPDATE_SESSION_ATTRIBUTE_QUERY);
54      };
55    }
56  
57    @Bean("springSessionConversionService")
58    public ConversionService springSessionConversionService() {
59  
60      BasicPolymorphicTypeValidator.Builder builder = BasicPolymorphicTypeValidator.builder()
61          .allowIfSubType("org.tailormap.api.security.")
62          .allowIfSubType("org.springframework.security.")
63          .allowIfSubType("java.util.")
64          .allowIfSubType(java.lang.Number.class)
65          .allowIfSubType("java.time.")
66          .allowIfBaseType(Object.class);
67  
68      JsonMapper mapper = JsonMapper.builder()
69          .configure(
70              StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION,
71              (logger.isDebugEnabled() || logger.isTraceEnabled()))
72          .configure(SerializationFeature.INDENT_OUTPUT, (logger.isDebugEnabled() || logger.isTraceEnabled()))
73  
74          // register mixins early so Jackson picks up the @JsonCreator constructor for TailormapUserDetails
75          // implementations
76          .addMixIn(
77              org.tailormap.api.security.TailormapUserDetailsImpl.class,
78              org.tailormap.api.security.TailormapUserDetailsImplMixin.class)
79          .addMixIn(
80              org.tailormap.api.security.TailormapOidcUser.class,
81              org.tailormap.api.security.TailormapOidcUserMixin.class)
82          .addModules(SecurityJacksonModules.getModules(this.classLoader, builder))
83          .activateDefaultTyping(builder.build(), DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY)
84          .build();
85  
86      final GenericConversionService converter = new GenericConversionService();
87      // Object -> byte[] (serialize to JSON bytes)
88      converter.addConverter(Object.class, byte[].class, source -> {
89        try {
90          logger.debug("Serializing Spring Session: {}", source);
91          return mapper.writerFor(Object.class).writeValueAsBytes(source);
92        } catch (JacksonException e) {
93          logger.error("Error serializing Spring Session object: {}", source, e);
94          throw new RuntimeException("Unable to serialize Spring Session.", e);
95        }
96      });
97      // byte[] -> Object (deserialize from JSON bytes)
98      converter.addConverter(byte[].class, Object.class, source -> {
99        try {
100         logger.debug(
101             "Deserializing Spring Session from bytes, length: {} ({})",
102             source.length,
103             new String(source, StandardCharsets.UTF_8));
104         return mapper.readValue(source, Object.class);
105       } catch (JacksonException e) {
106         String preview;
107         try {
108           String content = new String(source, StandardCharsets.UTF_8);
109           int maxLength = 256;
110           preview = content.length() > maxLength ? content.substring(0, maxLength) + "..." : content;
111         } catch (Exception ex) {
112           preview = "<unavailable>";
113         }
114         logger.error(
115             "Error deserializing Spring Session from bytes, length: {}, preview: {}",
116             source.length,
117             preview,
118             e);
119         throw new RuntimeException("Unable to deserialize Spring Session.", e);
120       }
121     });
122 
123     return converter;
124   }
125 
126   @Override
127   public void setBeanClassLoader(@NonNull ClassLoader classLoader) {
128     this.classLoader = classLoader;
129   }
130 }